/**
 * Custom control icon used to display Map Location journal pages when pinned to the map.
 */
class MapLocationControlIcon extends PIXI.Container {
  constructor({code, size=40, ...style}={}, ...args) {
    super(...args);

    this.code = code;
    this.size = size;
    this.style = style;

    this.renderMarker();
    this.refresh();
  }

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

  /**
   * Perform the actual rendering of the marker.
   */
  renderMarker() {
    this.radius = this.size / 2;
    this.circle = [this.radius, this.radius, this.radius + 8];
    this.backgroundColor = this.style.backgroundColor;
    this.borderColor = this.style.borderHoverColor;

    // Define hit area
    this.eventMode = "static";
    this.interactiveChildren = false;
    this.hitArea = new PIXI.Circle(...this.circle);
    this.cursor = "pointer";

    // Drop Shadow
    this.shadow = this.addChild(new PIXI.Graphics());
    this.shadow.clear()
      .beginFill(this.style.shadowColor, 0.65)
      .drawCircle(this.radius + 8, this.radius + 8, this.radius + 10)
      .endFill();
    this.shadow.filters = [new PIXI.filters.BlurFilter(16)];

    // 3D Effect
    this.extrude = this.addChild(new PIXI.Graphics());
    this.extrude.clear()
      .beginFill(this.style.borderColor, 1.0)
      .drawCircle(this.radius + 2, this.radius + 2, this.radius + 9)
      .endFill();

    // Background
    this.bg = this.addChild(new PIXI.Graphics());
    this.bg.clear()
      .beginFill(this.backgroundColor, 1.0)
      .lineStyle(2, this.style.borderColor, 1.0)
      .drawCircle(...this.circle)
      .endFill();

    // Text
    this.text = new PreciseText(this.code, this._getTextStyle(this.code.length, this.size));
    this.text.anchor.set(0.5, 0.5);
    this.text.position.set(this.radius, this.radius);
    this.addChild(this.text);

    // Border
    this.border = this.addChild(new PIXI.Graphics());
    this.border.visible = false;
  }

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

  /**
   * Code text to be rendered.
   * @type {string}
   */
  code;

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

  /** @inheritDoc */
  refresh({ visible, iconColor, borderColor, borderVisible }={}) {
    if ( borderColor ) this.borderColor = borderColor;
    this.border.clear().lineStyle(2, this.borderColor, 1.0).drawCircle(...this.circle).endFill();
    if ( borderVisible !== undefined ) this.border.visible = borderVisible;
    if ( visible !== undefined ) this.visible = visible;
    return this;
  }

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

  /**
   * Define PIXI TestStyle object for rendering the map location code.
   * @param {number} characterCount  Number of characters in the code.
   * @param {number} size            Size of the icon in the Scene.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle(characterCount, size) {
    const style = CONFIG.canvasTextStyle.clone();
    style.dropShadow = false;
    style.fill = Color.from(this.style.textColor);
    style.strokeThickness = 0;
    style.fontFamily = ["Signika"];
    if ( this.style.fontFamily ) style.fontFamily.unshift(this.style.fontFamily);
    style.fontSize = characterCount > 2 ? size * .5 : size * .6;
    return style;
  }
}

const {
  Coin, DiceTerm: DiceTerm$3, Die: Die$1, FunctionTerm: FunctionTerm$1, NumericTerm: NumericTerm$2, OperatorTerm: OperatorTerm$3, ParentheticalTerm: ParentheticalTerm$1, RollTerm: RollTerm$1
} = foundry.dice.terms;

/**
 * A standardized helper function for simplifying the constant parts of a multipart roll formula.
 *
 * @param {string} formula                          The original roll formula.
 * @param {object} [options]                        Formatting options.
 * @param {boolean} [options.preserveFlavor=false]  Preserve flavor text in the simplified formula.
 * @param {boolean} [options.deterministic]         Strip any non-deterministic terms from the result.
 *
 * @returns {string}  The resulting simplified formula.
 */
function simplifyRollFormula(formula, { preserveFlavor=false, deterministic=false } = {}) {
  // Create a new roll and verify that the formula is valid before attempting simplification.
  let roll;
  try { roll = new Roll(formula); }
  catch(err) { console.warn(`Unable to simplify formula '${formula}': ${err}`); }
  Roll.validate(roll.formula);

  // Optionally strip flavor annotations.
  if ( !preserveFlavor ) roll.terms = Roll.parse(roll.formula.replace(RollTerm$1.FLAVOR_REGEXP, ""));

  if ( deterministic ) {
    // Perform arithmetic simplification to simplify parsing through the terms.
    roll.terms = _simplifyOperatorTerms(roll.terms);

    // Remove non-deterministic terms, their preceding operators, and dependent operators/terms.
    const terms = [];
    let temp = [];
    let multiplicative = false;
    let determ;

    for ( let i = roll.terms.length - 1; i >= 0; ) {
      let paren;
      let term = roll.terms[i];
      if ( term instanceof ParentheticalTerm$1 ) {
        paren = simplifyRollFormula(term.term, { preserveFlavor, deterministic });
      }
      if ( Number.isNumeric(paren) ) {
        const termData = { number: paren };
        if ( preserveFlavor ) termData.options = { flavor: term.flavor };
        term = new NumericTerm$2(termData);
      }
      determ = term.isDeterministic && (!multiplicative || determ);
      if ( determ ) temp.unshift(term);
      else temp = [];
      term = roll.terms[--i];
      while ( term instanceof OperatorTerm$3 ) {
        if ( determ ) temp.unshift(term);
        if ( (term.operator === "*") || (term.operator === "/") || (term.operator === "%") ) multiplicative = true;
        else {
          multiplicative = false;
          while ( temp.length ) terms.unshift(temp.pop());
        }
        term = roll.terms[--i];
      }
    }
    if ( determ ) {
      while ( temp.length ) terms.unshift(temp.pop());
    }
    roll.terms = terms;
  }

  // Perform arithmetic simplification on the existing roll terms.
  roll.terms = _simplifyOperatorTerms(roll.terms);

  // If the formula contains multiplication or division we cannot easily simplify
  if ( /[*/]/.test(roll.formula) ) {
    if ( roll.isDeterministic && !/d\(/.test(roll.formula) && (!/\[/.test(roll.formula) || !preserveFlavor) ) {
      return String(new Roll(roll.formula).evaluateSync().total);
    }
    else return roll.constructor.getFormula(roll.terms);
  }

  // Flatten the roll formula and eliminate string terms.
  roll.terms = _expandParentheticalTerms(roll.terms);
  roll.terms = Roll.simplifyTerms(roll.terms);

  // Group terms by type and perform simplifications on various types of roll term.
  let { poolTerms, diceTerms, mathTerms, numericTerms } = _groupTermsByType(roll.terms);
  numericTerms = _simplifyNumericTerms(numericTerms ?? []);
  diceTerms = _simplifyDiceTerms(diceTerms ?? []);

  // Recombine the terms into a single term array and remove an initial + operator if present.
  const simplifiedTerms = [diceTerms, poolTerms, mathTerms, numericTerms].flat().filter(Boolean);
  if ( simplifiedTerms[0]?.operator === "+" ) simplifiedTerms.shift();
  return roll.constructor.getFormula(simplifiedTerms);
}

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

/**
 * A helper function to perform arithmetic simplification and remove redundant operator terms.
 * @param {RollTerm[]} terms  An array of roll terms.
 * @returns {RollTerm[]}      A new array of roll terms with redundant operators removed.
 */
function _simplifyOperatorTerms(terms) {
  return terms.reduce((acc, term) => {
    const prior = acc[acc.length - 1];
    const ops = new Set([prior?.operator, term.operator]);

    // If one of the terms is not an operator, add the current term as is.
    if ( ops.has(undefined) ) acc.push(term);

    // Replace consecutive "+ -" operators with a "-" operator.
    else if ( (ops.has("+")) && (ops.has("-")) ) acc.splice(-1, 1, new OperatorTerm$3({ operator: "-" }));

    // Replace double "-" operators with a "+" operator.
    else if ( (ops.has("-")) && (ops.size === 1) ) acc.splice(-1, 1, new OperatorTerm$3({ operator: "+" }));

    // Don't include "+" operators that directly follow "+", "*", or "/". Otherwise, add the term as is.
    else if ( !ops.has("+") ) acc.push(term);

    return acc;
  }, []);
}

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

/**
 * A helper function for combining unannotated numeric terms in an array into a single numeric term.
 * @param {object[]} terms  An array of roll terms.
 * @returns {object[]}      A new array of terms with unannotated numeric terms combined into one.
 */
function _simplifyNumericTerms(terms) {
  const simplified = [];
  const { annotated, unannotated } = _separateAnnotatedTerms(terms);

  // Combine the unannotated numerical bonuses into a single new NumericTerm.
  if ( unannotated.length ) {
    const staticBonus = Roll.safeEval(Roll.getFormula(unannotated));
    if ( staticBonus === 0 ) return [...annotated];

    // If the staticBonus is greater than 0, add a "+" operator so the formula remains valid.
    if ( staticBonus > 0 ) simplified.push(new OperatorTerm$3({ operator: "+"}));
    simplified.push(new NumericTerm$2({ number: staticBonus} ));
  }
  return [...simplified, ...annotated];
}

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

/**
 * A helper function to group dice of the same size and sign into single dice terms.
 * @param {object[]} terms  An array of DiceTerms and associated OperatorTerms.
 * @returns {object[]}      A new array of simplified dice terms.
 */
function _simplifyDiceTerms(terms) {
  const { annotated, unannotated } = _separateAnnotatedTerms(terms);

  // Split the unannotated terms into different die sizes and signs
  const diceQuantities = unannotated.reduce((obj, curr, i) => {
    if ( curr instanceof OperatorTerm$3 ) return obj;
    const isCoin = curr.constructor?.name === "Coin";
    const face = isCoin ? "c" : curr.faces;
    const modifiers = isCoin ? "" : curr.modifiers.filterJoin("");
    const key = `${unannotated[i - 1].operator}${face}${modifiers}`;
    obj[key] ??= {};
    if ( (curr._number instanceof Roll) && (curr._number.isDeterministic) ) curr._number.evaluateSync();
    obj[key].number = (obj[key].number ?? 0) + curr.number;
    if ( !isCoin ) obj[key].modifiers = (obj[key].modifiers ?? []).concat(curr.modifiers);
    return obj;
  }, {});

  // Add new die and operator terms to simplified for each die size and sign
  const simplified = Object.entries(diceQuantities).flatMap(([key, {number, modifiers}]) => ([
    new OperatorTerm$3({ operator: key.charAt(0) }),
    key.slice(1) === "c"
      ? new Coin({ number: number })
      : new Die$1({ number, faces: parseInt(key.slice(1)), modifiers: [...new Set(modifiers)] })
  ]));
  return [...simplified, ...annotated];
}

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

/**
 * A helper function to extract the contents of parenthetical terms into their own terms.
 * @param {object[]} terms  An array of roll terms.
 * @returns {object[]}      A new array of terms with no parenthetical terms.
 */
function _expandParentheticalTerms(terms) {
  terms = terms.reduce((acc, term) => {
    if ( term instanceof ParentheticalTerm$1 ) {
      if ( term.isDeterministic ) {
        const roll = new Roll(term.term);
        term = new NumericTerm$2({ number: roll.evaluateSync().total });
      } else {
        const subterms = new Roll(term.term).terms;
        term = _expandParentheticalTerms(subterms);
      }
    }
    acc.push(term);
    return acc;
  }, []);
  return _simplifyOperatorTerms(terms.flat());
}

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

/**
 * A helper function to group terms into PoolTerms, DiceTerms, FunctionTerms, and NumericTerms.
 * FunctionTerms are included as NumericTerms if they are deterministic.
 * @param {RollTerm[]} terms  An array of roll terms.
 * @returns {object}          An object mapping term types to arrays containing roll terms of that type.
 */
function _groupTermsByType(terms) {
  // Add an initial operator so that terms can be rearranged arbitrarily.
  if ( !(terms[0] instanceof OperatorTerm$3) ) terms.unshift(new OperatorTerm$3({ operator: "+" }));

  return terms.reduce((obj, term, i) => {
    let type;
    if ( term instanceof DiceTerm$3 ) type = DiceTerm$3;
    else if ( (term instanceof FunctionTerm$1) && (term.isDeterministic) ) type = NumericTerm$2;
    else type = term.constructor;
    const key = `${type.name.charAt(0).toLowerCase()}${type.name.substring(1)}s`;

    // Push the term and the preceding OperatorTerm.
    (obj[key] = obj[key] ?? []).push(terms[i - 1], term);
    return obj;
  }, {});
}

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

/**
 * A helper function to separate annotated terms from unannotated terms.
 * @param {object[]} terms     An array of DiceTerms and associated OperatorTerms.
 * @returns {Array | Array[]}  A pair of term arrays, one containing annotated terms.
 */
function _separateAnnotatedTerms(terms) {
  return terms.reduce((obj, curr, i) => {
    if ( curr instanceof OperatorTerm$3 ) return obj;
    obj[curr.flavor ? "annotated" : "unannotated"].push(terms[i - 1], curr);
    return obj;
  }, { annotated: [], unannotated: [] });
}

/* -------------------------------------------- */
/*  Formatters                                  */
/* -------------------------------------------- */

/**
 * Format a Challenge Rating using the proper fractional symbols.
 * @param {number} value                   CR value to format.
 * @param {object} [options={}]
 * @param {boolean} [options.narrow=true]  Use narrow fractions (e.g. ⅛) rather than wide ones (e.g. 1/8).
 * @returns {string}
 */
function formatCR(value, { narrow=true }={}) {
  if ( value === null ) return "—";
  const fractions = narrow ? { 0.125: "⅛", 0.25: "¼", 0.5: "½" } : { 0.125: "1/8", 0.25: "1/4", 0.5: "1/2" };
  return fractions[value] ?? formatNumber(value);
}

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

/**
 * Form a number using the provided length unit.
 * @param {number} value         The length to format.
 * @param {string} unit          Length unit as defined in `CONFIG.DND5E.movementUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatLength(value, unit, options={}) {
  return _formatSystemUnits(value, unit, CONFIG.DND5E.movementUnits[unit], options);
}

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

/**
 * Format a modifier for display with its sign separate.
 * @param {number} mod  The modifier.
 * @returns {Handlebars.SafeString}
 */
function formatModifier(mod) {
  if ( !Number.isFinite(mod) ) return new Handlebars.SafeString("—");
  return new Handlebars.SafeString(`<span class="sign">${mod < 0 ? "-" : "+"}</span>${Math.abs(mod)}`);
}

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

/**
 * A helper for using Intl.NumberFormat within handlebars.
 * @param {number} value    The value to format.
 * @param {object} options  Options forwarded to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat}
 * @param {boolean} [options.numerals]  Format the number as roman numerals.
 * @param {boolean} [options.ordinal]   Use ordinal formatting.
 * @param {boolean} [options.words]     Write out number as full word, if possible.
 * @returns {string}
 */
function formatNumber(value, { numerals, ordinal, words, ...options }={}) {
  if ( words && game.i18n.has(`DND5E.NUMBER.${value}`, false) ) return game.i18n.localize(`DND5E.NUMBER.${value}`);
  if ( numerals ) return _formatNumberAsNumerals(value);
  if ( ordinal ) return _formatNumberAsOrdinal(value, options);
  const formatter = new Intl.NumberFormat(game.i18n.lang, options);
  return formatter.format(value);
}

/**
 * Roman numerals.
 * @type {Record<string, number>}
 */
const _roman = {
  M: 1000, CM: 900, D: 500, CD: 400, C: 100, XC: 90, L: 50, XL: 40, X: 10, IX: 9, V: 5, IV: 4, I: 1
};

/**
 * Format a number as roman numerals.
 * @param {number} n  The number to format.
 * @returns {string}
 */
function _formatNumberAsNumerals(n) {
  let out = "";
  if ( (n < 1) || !Number.isInteger(n) ) return out;
  for ( const [numeral, decimal] of Object.entries(_roman) ) {
    const quotient = Math.floor(n / decimal);
    n -= quotient * decimal;
    out += numeral.repeat(quotient);
  }
  return out;
}

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

/**
 * Format a number using an ordinal format.
 * @param {number} n        The number to format.
 * @param {object} options  Options forwarded to `formatNumber`.
 * @returns {string}
 */
function _formatNumberAsOrdinal(n, options={}) {
  const pr = getPluralRules({ type: "ordinal" }).select(n);
  const number = formatNumber(n, options);
  return game.i18n.has(`DND5E.ORDINAL.${pr}`) ? game.i18n.format(`DND5E.ORDINAL.${pr}`, { number }) : number;
}

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

/**
 * Produce a number with the parts wrapped in their own spans.
 * @param {number} value      A number for format.
 * @param {object} [options]  Formatting options.
 * @returns {string}
 */
function formatNumberParts(value, options) {
  if ( options.numerals ) throw new Error("Cannot segment numbers when formatted as numerals.");
  return new Intl.NumberFormat(game.i18n.lang, options).formatToParts(value)
    .reduce((str, { type, value }) => `${str}<span class="${type}">${value}</span>`, "");
}

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

/**
 * A helper for using Intl.NumberFormat within handlebars for format a range.
 * @param {number} min      The lower end of the range.
 * @param {number} max      The upper end of the range.
 * @param {object} options  Options forwarded to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat}
 * @returns {string}
 */
function formatRange(min, max, options) {
  const formatter = new Intl.NumberFormat(game.i18n.lang, options);
  return formatter.formatRange(min, max);
}

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

/**
 * A helper function to format textarea text to HTML with linebreaks.
 * @param {string} value  The text to format.
 * @returns {Handlebars.SafeString}
 */
function formatText(value) {
  return new Handlebars.SafeString(value?.replaceAll("\n", "<br>") ?? "");
}

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

/**
 * A helper function that formats a time in a human-readable format.
 * @param {number} value         Time to display.
 * @param {string} unit          Units as defined in `CONFIG.DND5E.timeUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatTime(value, unit, options={}) {
  options.maximumFractionDigits ??= 0;
  options.unitDisplay ??= "long";
  const config = CONFIG.DND5E.timeUnits[unit];
  if ( config?.counted ) {
    if ( (options.unitDisplay === "narrow") && game.i18n.has(`${config.counted}.narrow`) ) {
      return game.i18n.format(`${config.counted}.narrow`, { number: formatNumber(value, options) });
    } else {
      const pr = new Intl.PluralRules(game.i18n.lang);
      return game.i18n.format(`${config.counted}.${pr.select(value)}`, { number: formatNumber(value, options) });
    }
  }
  try {
    return formatNumber(value, { ...options, style: "unit", unit });
  } catch(err) {
    return formatNumber(value, options);
  }
}

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

/**
 * Form a number using the provided volume unit.
 * @param {number} value         The volume to format.
 * @param {string} unit          Volume unit as defined in `CONFIG.DND5E.volumeUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatVolume(value, unit, options={}) {
  return _formatSystemUnits(value, unit, CONFIG.DND5E.volumeUnits[unit], options);
}

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

/**
 * Form a number using the provided weight unit.
 * @param {number} value         The weight to format.
 * @param {string} unit          Weight unit as defined in `CONFIG.DND5E.weightUnits`.
 * @param {object} [options={}]  Formatting options passed to `formatNumber`.
 * @returns {string}
 */
function formatWeight(value, unit, options={}) {
  return _formatSystemUnits(value, unit, CONFIG.DND5E.weightUnits[unit], options);
}

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

/**
 * Format a number using one of core's built-in unit types.
 * @param {number} value                   Value to display.
 * @param {string} unit                    Name of the unit to use.
 * @param {UnitConfiguration} config       Configuration data for the unit.
 * @param {object} [options={}]            Formatting options passed to `formatNumber`.
 * @param {boolean} [options.parts=false]  Format to parts.
 * @returns {string}
 */
function _formatSystemUnits(value, unit, config, { parts=false, ...options }={}) {
  options.unitDisplay ??= "short";
  if ( config?.counted ) {
    const localizationKey = `${config.counted}.${options.unitDisplay}.${getPluralRules().select(value)}`;
    return game.i18n.format(localizationKey, { number: formatNumber(value, options) });
  }
  unit = config?.formattingUnit ?? unit;
  if ( isValidUnit(unit) ) {
    options.style ??= "unit";
    options.unit ??= unit;
  }
  return (parts ? formatNumberParts : formatNumber)(value, options);
}

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

/**
 * Cached store of Intl.PluralRules instances.
 * @type {Record<string, Intl.PluralRules>}
 */
const _pluralRules = {};

/**
 * Get a PluralRules object, fetching from cache if possible.
 * @param {object} [options={}]
 * @param {string} [options.type=cardinal]
 * @returns {Intl.PluralRules}
 */
function getPluralRules({ type="cardinal" }={}) {
  _pluralRules[type] ??= new Intl.PluralRules(game.i18n.lang, { type });
  return _pluralRules[type];
}

/* -------------------------------------------- */
/*  Formulas                                    */
/* -------------------------------------------- */

/**
 * Return whether a string is a valid reroll, explosion, min, or max dice modifier.
 * @param {string} mod      The modifier to test.
 * @returns {boolean}
 */
function isValidDieModifier(mod) {
  const regex = {
    reroll: /rr?([0-9]+)?([<>=]+)?([0-9]+)?/i,
    explode: /xo?([0-9]+)?([<>=]+)?([0-9]+)?/i,
    minimum: /(?:min)([0-9]+)/i,
    maximum: /(?:max)([0-9]+)/i,
    dropKeep: /[dk]([hl])?([0-9]+)?/i,
    count: /(?:c[sf])([<>=]+)?([0-9]+)?/i
  };
  return Object.values(regex).some(rgx => rgx.test(mod));
}

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

/**
 * Handle a delta input for a number value from a form.
 * @param {HTMLInputElement} input  Input that contains the modified value.
 * @param {Document} target         Target document to be updated.
 * @returns {number|void}
 */
function parseInputDelta(input, target) {
  let value = input.value;
  if ( ["+", "-"].includes(value[0]) ) {
    const delta = parseFloat(value);
    value = Number(foundry.utils.getProperty(target, input.dataset.name ?? input.name)) + delta;
  }
  else if ( value[0] === "=" ) value = Number(value.slice(1));
  if ( Number.isNaN(value) ) return;
  input.value = value;
  return value;
}

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

/**
 * Prepare the final formula value for a model field.
 * @param {ItemDataModel|BaseActivityData} model  Model for which the value is being prepared.
 * @param {string} keyPath                        Path to the field within the model.
 * @param {string} label                          Label to use in preparation warnings.
 * @param {object} rollData                       Roll data to use when replacing formula values.
 */
function prepareFormulaValue(model, keyPath, label, rollData) {
  const value = foundry.utils.getProperty(model, keyPath);
  if ( !value ) return;
  const item = model.item ?? model.parent;
  const property = game.i18n.localize(label);
  try {
    const formula = replaceFormulaData(value, rollData, { item, property });
    const roll = new Roll(formula);
    foundry.utils.setProperty(model, keyPath, roll.evaluateSync().total);
  } catch(err) {
    if ( item.isEmbedded ) {
      const message = game.i18n.format("DND5E.FormulaMalformedError", { property, name: model.name ?? item.name });
      item.actor._preparationWarnings.push({ message, link: item.uuid, type: "error" });
      console.error(message, err);
    }
  }
}

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

/**
 * Replace referenced data attributes in the roll formula with values from the provided data.
 * If the attribute is not found in the provided data, display a warning on the actor.
 * @param {string} formula           The original formula within which to replace.
 * @param {object} data              The data object which provides replacements.
 * @param {object} [options={}]
 * @param {Actor5e} [options.actor]            Actor for which the value is being prepared.
 * @param {Item5e} [options.item]              Item for which the value is being prepared.
 * @param {string|null} [options.missing="0"]  Value to use when replacing missing references, or `null` to not replace.
 * @param {string} [options.property]          Name of the property to which this formula belongs.
 * @returns {string}                 Formula with replaced data.
 */
function replaceFormulaData(formula, data, { actor, item, missing="0", property }={}) {
  const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
  const missingReferences = new Set();
  formula = String(formula).replace(dataRgx, (match, term) => {
    let value = foundry.utils.getProperty(data, term);
    if ( value == null ) {
      missingReferences.add(match);
      return missing ?? match[0];
    }
    return String(value).trim();
  });
  actor ??= item?.parent;
  if ( (missingReferences.size > 0) && actor && property ) {
    const listFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
    const message = game.i18n.format("DND5E.FormulaMissingReferenceWarn", {
      property, name: item?.name ?? actor.name, references: listFormatter.format(missingReferences)
    });
    actor._preparationWarnings.push({ message, link: item?.uuid ?? actor.uuid, type: "warning" });
  }
  return formula;
}

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

/**
 * Convert a bonus value to a simple integer for displaying on the sheet.
 * @param {number|string|null} bonus  Bonus formula.
 * @param {object} [data={}]          Data to use for replacing @ strings.
 * @returns {number}                  Simplified bonus as an integer.
 * @protected
 */
function simplifyBonus(bonus, data={}) {
  if ( !bonus ) return 0;
  if ( Number.isNumeric(bonus) ) return Number(bonus);
  try {
    const roll = new Roll(bonus, data);
    return roll.isDeterministic ? roll.evaluateSync().total : 0;
  } catch(error) {
    console.error(error);
    return 0;
  }
}

/* -------------------------------------------- */
/*  IDs                                         */
/* -------------------------------------------- */

/**
 * Create an ID from the input truncating or padding the value to make it reach 16 characters.
 * @param {string} id
 * @returns {string}
 */
function staticID(id) {
  if ( id.length >= 16 ) return id.substring(0, 16);
  return id.padEnd(16, "0");
}

/* -------------------------------------------- */
/*  Keybindings Helper                          */
/* -------------------------------------------- */

/**
 * Track which KeyboardEvent#code presses associate with each modifier.
 * Added support for treating Meta separate from Control.
 * @enum {string[]}
 */
const MODIFIER_CODES = {
  Alt: KeyboardManager.MODIFIER_CODES.Alt,
  Control: KeyboardManager.MODIFIER_CODES.Control.filter(k => k.startsWith("Control")),
  Meta: KeyboardManager.MODIFIER_CODES.Control.filter(k => !k.startsWith("Control")),
  Shift: KeyboardManager.MODIFIER_CODES.Shift
};

/**
 * Based on the provided event, determine if the keys are pressed to fulfill the specified keybinding.
 * @param {Event} event    Triggering event.
 * @param {string} action  Keybinding action within the `dnd5e` namespace.
 * @returns {boolean}      Is the keybinding triggered?
 */
function areKeysPressed(event, action) {
  if ( !event ) return false;
  const activeModifiers = {};
  const addModifiers = (key, pressed) => {
    activeModifiers[key] = pressed;
    MODIFIER_CODES[key].forEach(n => activeModifiers[n] = pressed);
  };
  addModifiers(KeyboardManager.MODIFIER_KEYS.ALT, event.altKey);
  addModifiers(KeyboardManager.MODIFIER_KEYS.CONTROL, event.ctrlKey);
  addModifiers("Meta", event.metaKey);
  addModifiers(KeyboardManager.MODIFIER_KEYS.SHIFT, event.shiftKey);
  return game.keybindings.get("dnd5e", action).some(b => {
    if ( game.keyboard.downKeys.has(b.key) && b.modifiers.every(m => activeModifiers[m]) ) return true;
    if ( b.modifiers.length ) return false;
    return activeModifiers[b.key];
  });
}

/* -------------------------------------------- */
/*  Logging                                     */
/* -------------------------------------------- */

/**
 * Log a console message with the "D&D 5e" prefix and styling.
 * @param {string} message                    Message to display.
 * @param {object} [options={}]
 * @param {string} [options.color="#6e0000"]  Color to use for the log.
 * @param {any[]} [options.extras=[]]         Extra options passed to the logging method.
 * @param {string} [options.level="log"]      Console logging method to call.
 */
function log(message, { color="#6e0000", extras=[], level="log" }={}) {
  console[level](
    `%cD&D 5e | %c${message}`, `color: ${color}; font-variant: small-caps`, "color: revert", ...extras
  );
}

/* -------------------------------------------- */
/*  Object Helpers                              */
/* -------------------------------------------- */

/**
 * Transform an object, returning only the keys which match the provided filter.
 * @param {object} obj         Object to transform.
 * @param {Function} [filter]  Filtering function. If none is provided, it will just check for truthiness.
 * @returns {string[]}         Array of filtered keys.
 */
function filteredKeys(obj, filter) {
  filter ??= e => e;
  return Object.entries(obj).filter(e => filter(e[1])).map(e => e[0]);
}

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

/**
 * Check whether an object exists without transversing any getters, preventing any deprecation warnings from triggering.
 * @param {object} object
 * @param {string} keyPath
 * @returns {boolean}
 */
function safePropertyExists(object, keyPath) {
  const parts = keyPath.split(".");
  for ( const part of parts ) {
    const descriptor = Object.getOwnPropertyDescriptor(object, part);
    if ( !descriptor || !("value" in descriptor) ) return false;
    object = object[part];
  }
  return true;
}

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

/**
 * Sort the provided object by its values or by an inner sortKey.
 * @param {object} obj                 The object to sort.
 * @param {string|Function} [sortKey]  An inner key upon which to sort or sorting function.
 * @returns {object}                   A copy of the original object that has been sorted.
 */
function sortObjectEntries(obj, sortKey) {
  let sorted = Object.entries(obj);
  const sort = (lhs, rhs) => foundry.utils.getType(lhs) === "string" ? lhs.localeCompare(rhs, game.i18n.lang) : lhs - rhs;
  if ( foundry.utils.getType(sortKey) === "function" ) sorted = sorted.sort((lhs, rhs) => sortKey(lhs[1], rhs[1]));
  else if ( sortKey ) sorted = sorted.sort((lhs, rhs) => sort(lhs[1][sortKey], rhs[1][sortKey]));
  else sorted = sorted.sort((lhs, rhs) => sort(lhs[1], rhs[1]));
  return Object.fromEntries(sorted);
}

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

/**
 * Retrieve the indexed data for a Document using its UUID. Will never return a result for embedded documents.
 * @param {string} uuid  The UUID of the Document index to retrieve.
 * @returns {object}     Document's index if one could be found.
 */
function indexFromUuid(uuid) {
  const parts = uuid.split(".");
  let index;

  // Compendium Documents
  if ( parts[0] === "Compendium" ) {
    const [, scope, packName, id] = parts;
    const pack = game.packs.get(`${scope}.${packName}`);
    index = pack?.index.get(id);
  }

  // World Documents
  else if ( parts.length < 3 ) {
    const [docName, id] = parts;
    const collection = CONFIG[docName].collection.instance;
    index = collection.get(id);
  }

  return index || null;
}

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

/**
 * Creates an HTML document link for the provided UUID.
 * Try to build links to compendium content synchronously to avoid DB lookups.
 * @param {string} uuid                    UUID for which to produce the link.
 * @param {object} [options]
 * @param {string} [options.tooltip]       Tooltip to add to the link.
 * @param {string} [options.renderBroken]  If a UUID cannot found, render it as a broken link instead of returning the
 *                                         empty string.
 * @returns {string}                       Link to the item or empty string if item wasn't found.
 */
function linkForUuid(uuid, { tooltip, renderBroken }={}) {
  let doc = fromUuidSync(uuid);
  if ( !doc ) {
    if ( renderBroken ) return `
      <a class="content-link broken" data-uuid="${uuid}">
        <i class="fas fa-unlink"></i> ${game.i18n.localize("Unknown")}
      </a>
    `;
    return "";
  }
  if ( uuid.startsWith("Compendium.") && !(doc instanceof foundry.abstract.Document) ) {
    const {collection} = foundry.utils.parseUuid(uuid);
    const cls = collection.documentClass;
    // Minimal "shell" of a document using index data
    doc = new cls(foundry.utils.deepClone(doc), {pack: collection.metadata.id});
  }
  const a = doc.toAnchor();
  if ( tooltip ) a.dataset.tooltip = tooltip;
  return a.outerHTML;
}

/* -------------------------------------------- */
/*  Targeting                                   */
/* -------------------------------------------- */

/**
 * Important information on a targeted token.
 *
 * @typedef {object} TargetDescriptor5e
 * @property {string} uuid  The UUID of the target.
 * @property {string} img   The target's image.
 * @property {string} name  The target's name.
 * @property {number} ac    The target's armor class, if applicable.
 */

/**
 * Grab the targeted tokens and return relevant information on them.
 * @returns {TargetDescriptor[]}
 */
function getTargetDescriptors() {
  const targets = new Map();
  for ( const token of game.user.targets ) {
    const { name } = token;
    const { img, system, uuid, statuses } = token.actor ?? {};
    if ( uuid ) {
      const ac = statuses.has("coverTotal") ? null : system.attributes?.ac?.value;
      targets.set(uuid, { name, img, uuid, ac: ac ?? null });
    }
  }
  return Array.from(targets.values());
}

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

/**
 * Get currently selected tokens in the scene or user's character's tokens.
 * @returns {Token5e[]}
 */
function getSceneTargets() {
  let targets = canvas.tokens?.controlled.filter(t => t.actor) ?? [];
  if ( !targets.length && game.user.character ) targets = game.user.character.getActiveTokens();
  return targets;
}

/* -------------------------------------------- */
/*  Conversions                                 */
/* -------------------------------------------- */

/**
 * Convert the provided length to another unit.
 * @param {number} value                   The length being converted.
 * @param {string} from                    The initial units.
 * @param {string} to                      The final units.
 * @param {object} [options={}]
 * @param {boolean} [options.strict=true]  Throw an error if either unit isn't found.
 * @returns {number}
 */
function convertLength(value, from, to, { strict=true }={}) {
  const message = unit => `Length unit ${unit} not defined in CONFIG.DND5E.movementUnits`;
  return _convertSystemUnits(value, from, to, CONFIG.DND5E.movementUnits, { message, strict });
}

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

/**
 * Convert the provided time value to another unit. If no final unit is provided, then will convert it to the largest
 * unit that can still represent the value as a whole number.
 * @param {number} value                    The time being converted.
 * @param {string} from                     The initial unit as defined in `CONFIG.DND5E.timeUnits`.
 * @param {object} [options={}]
 * @param {boolean} [options.combat=false]  Use combat units when auto-selecting units, rather than normal units.
 * @param {boolean} [options.strict=true]   Throw an error if from unit isn't found.
 * @param {string} [options.to]             The final units, if explicitly provided.
 * @returns {{ value: number, unit: string }}
 */
function convertTime(value, from, { combat=false, strict=true, to }={}) {
  const base = value * (CONFIG.DND5E.timeUnits[from]?.conversion ?? 1);
  if ( !to ) {
    // Find unit with largest conversion value that can still display the value
    const unitOptions = Object.entries(CONFIG.DND5E.timeUnits)
      .reduce((arr, [key, v]) => {
        if ( ((v.combat ?? false) === combat) && ((base % v.conversion === 0) || (base >= v.conversion * 2)) ) {
          arr.push({ key, conversion: v.conversion });
        }
        return arr;
      }, [])
      .sort((lhs, rhs) => rhs.conversion - lhs.conversion);
    to = unitOptions[0]?.key ?? from;
  }

  const message = unit => `Time unit ${unit} not defined in CONFIG.DND5E.timeUnits`;
  return { value: _convertSystemUnits(value, from, to, CONFIG.DND5E.timeUnits, { message, strict }), unit: to };
}

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

/**
 * Convert the provided weight to another unit.
 * @param {number} value                   The weight being converted.
 * @param {string} from                    The initial unit as defined in `CONFIG.DND5E.weightUnits`.
 * @param {string} to                      The final units.
 * @param {object} [options={}]
 * @param {boolean} [options.strict=true]  Throw an error if either unit isn't found.
 * @returns {number}      Weight in the specified units.
 */
function convertWeight(value, from, to, { strict=true }={}) {
  const message = unit => `Weight unit ${unit} not defined in CONFIG.DND5E.weightUnits`;
  return _convertSystemUnits(value, from, to, CONFIG.DND5E.weightUnits, { message, strict });
}

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

/**
 * Convert from one unit to another using one of core's built-in unit types.
 * @param {number} value                                Value to display.
 * @param {string} from                                 The initial unit.
 * @param {string} to                                   The final unit.
 * @param {UnitConfiguration} config                    Configuration data for the unit.
 * @param {object} options
 * @param {function(string): string} [options.message]  Method used to produce the error message if unit not found.
 * @param {boolean} [options.strict]                    Throw an error if either unit isn't found.
 * @returns {string}
 */
function _convertSystemUnits(value, from, to, config, { message, strict }) {
  if ( from === to ) return value;
  if ( strict && !config[from] ) throw new Error(message(from));
  if ( strict && !config[to] ) throw new Error(message(to));
  return value * (config[from]?.conversion ?? 1) / (config[to]?.conversion ?? 1);
}

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

/**
 * Default units to use depending on system setting.
 * @param {"length"|"weight"} type  Type of units to select.
 * @returns {string}
 */
function defaultUnits(type) {
  return CONFIG.DND5E.defaultUnits[type]?.[
    game.settings.get("dnd5e", `metric${type.capitalize()}Units`) ? "metric" : "imperial"
  ];
}

/* -------------------------------------------- */
/*  Validators                                  */
/* -------------------------------------------- */

/**
 * Ensure the provided string contains only the characters allowed in identifiers.
 * @param {string} identifier
 * @returns {boolean}
 */
function isValidIdentifier(identifier) {
  return /^([a-z0-9_-]+)$/i.test(identifier);
}

const validators = {
  isValidIdentifier: isValidIdentifier
};

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

/**
 * Determine whether the provided unit is usable within `Intl.NumberFormat`.
 * @param {string} unit
 * @returns {boolean}
 */
function isValidUnit(unit) {
  if ( unit?.includes("-per-") ) return unit.split("-per-").every(u => isValidUnit(u));
  return Intl.supportedValuesOf("unit").includes(unit);
}

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

/**
 * Define a set of template paths to pre-load. Pre-loaded templates are compiled and cached for fast access when
 * rendering. These paths will also be available as Handlebars partials by using the file name
 * (e.g. "dnd5e.actor-traits").
 * @returns {Promise}
 */
async function preloadHandlebarsTemplates() {
  const partials = [
    // Shared Partials
    "systems/dnd5e/templates/shared/active-effects.hbs",
    "systems/dnd5e/templates/shared/active-effects2.hbs",
    "systems/dnd5e/templates/shared/inventory.hbs",
    "systems/dnd5e/templates/shared/inventory2.hbs",
    "systems/dnd5e/templates/apps/parts/trait-list.hbs",
    "systems/dnd5e/templates/apps/parts/traits-list.hbs",

    // Actor Sheet Partials
    "systems/dnd5e/templates/actors/parts/actor-classes.hbs",
    "systems/dnd5e/templates/actors/parts/actor-trait-pills.hbs",
    "systems/dnd5e/templates/actors/parts/actor-traits.hbs",
    "systems/dnd5e/templates/actors/parts/actor-features.hbs",
    "systems/dnd5e/templates/actors/parts/actor-inventory.hbs",
    "systems/dnd5e/templates/actors/parts/actor-spellbook.hbs",
    "systems/dnd5e/templates/actors/parts/actor-warnings.hbs",
    "systems/dnd5e/templates/actors/parts/actor-warnings-dialog.hbs",
    "systems/dnd5e/templates/actors/parts/biography-textbox.hbs",
    "systems/dnd5e/templates/actors/tabs/character-bastion.hbs",
    "systems/dnd5e/templates/actors/tabs/character-biography.hbs",
    "systems/dnd5e/templates/actors/tabs/character-details.hbs",
    "systems/dnd5e/templates/actors/tabs/creature-features.hbs",
    "systems/dnd5e/templates/actors/tabs/creature-special-traits.hbs",
    "systems/dnd5e/templates/actors/tabs/creature-spells.hbs",
    "systems/dnd5e/templates/actors/tabs/group-members.hbs",
    "systems/dnd5e/templates/actors/tabs/npc-biography.hbs",

    // Actor Sheet Item Summary Columns
    "systems/dnd5e/templates/actors/parts/columns/column-feature-controls.hbs",
    "systems/dnd5e/templates/actors/parts/columns/column-formula.hbs",
    "systems/dnd5e/templates/actors/parts/columns/column-recovery.hbs",
    "systems/dnd5e/templates/actors/parts/columns/column-roll.hbs",
    "systems/dnd5e/templates/actors/parts/columns/column-uses.hbs",

    // Chat Message Partials
    "systems/dnd5e/templates/chat/parts/card-activities.hbs",
    "systems/dnd5e/templates/chat/parts/card-deltas.hbs",

    // Item Sheet Partials
    "systems/dnd5e/templates/items/details/details-background.hbs",
    "systems/dnd5e/templates/items/details/details-class.hbs",
    "systems/dnd5e/templates/items/details/details-consumable.hbs",
    "systems/dnd5e/templates/items/details/details-container.hbs",
    "systems/dnd5e/templates/items/details/details-equipment.hbs",
    "systems/dnd5e/templates/items/details/details-facility.hbs",
    "systems/dnd5e/templates/items/details/details-feat.hbs",
    "systems/dnd5e/templates/items/details/details-loot.hbs",
    "systems/dnd5e/templates/items/details/details-mountable.hbs",
    "systems/dnd5e/templates/items/details/details-species.hbs",
    "systems/dnd5e/templates/items/details/details-spell.hbs",
    "systems/dnd5e/templates/items/details/details-spellcasting.hbs",
    "systems/dnd5e/templates/items/details/details-starting-equipment.hbs",
    "systems/dnd5e/templates/items/details/details-subclass.hbs",
    "systems/dnd5e/templates/items/details/details-tool.hbs",
    "systems/dnd5e/templates/items/details/details-weapon.hbs",
    "systems/dnd5e/templates/items/parts/item-action.hbs",
    "systems/dnd5e/templates/items/parts/item-activation.hbs",
    "systems/dnd5e/templates/items/parts/item-activities.hbs",
    "systems/dnd5e/templates/items/parts/item-advancement.hbs",
    "systems/dnd5e/templates/items/parts/item-advancement2.hbs",
    "systems/dnd5e/templates/items/parts/item-description.hbs",
    "systems/dnd5e/templates/items/parts/item-description2.hbs",
    "systems/dnd5e/templates/items/parts/item-details.hbs",
    "systems/dnd5e/templates/items/parts/item-mountable.hbs",
    "systems/dnd5e/templates/items/parts/item-spellcasting.hbs",
    "systems/dnd5e/templates/items/parts/item-source.hbs",
    "systems/dnd5e/templates/items/parts/item-summary.hbs",
    "systems/dnd5e/templates/items/parts/item-tooltip.hbs",
    "systems/dnd5e/templates/items/parts/spell-block.hbs",

    // Field Partials
    "systems/dnd5e/templates/shared/fields/field-activation.hbs",
    "systems/dnd5e/templates/shared/fields/field-damage.hbs",
    "systems/dnd5e/templates/shared/fields/field-duration.hbs",
    "systems/dnd5e/templates/shared/fields/field-range.hbs",
    "systems/dnd5e/templates/shared/fields/field-targets.hbs",
    "systems/dnd5e/templates/shared/fields/field-uses.hbs",
    "systems/dnd5e/templates/shared/fields/fieldlist.hbs",

    // Journal Partials
    "systems/dnd5e/templates/journal/parts/journal-legacy-traits.hbs",
    "systems/dnd5e/templates/journal/parts/journal-modern-traits.hbs",
    "systems/dnd5e/templates/journal/parts/journal-table.hbs",

    // Activity Partials
    "systems/dnd5e/templates/activity/columns/activity-column-controls.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-formula.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-price.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-quantity.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-range.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-recovery.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-roll.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-school.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-target.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-time.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-uses.hbs",
    "systems/dnd5e/templates/activity/columns/activity-column-weight.hbs",
    "systems/dnd5e/templates/activity/activity-row-summary.hbs",
    "systems/dnd5e/templates/activity/parts/activity-usage-notes.hbs",

    // Advancement Partials
    "systems/dnd5e/templates/advancement/parts/advancement-ability-score-control.hbs",
    "systems/dnd5e/templates/advancement/parts/advancement-controls.hbs",
    "systems/dnd5e/templates/advancement/parts/advancement-spell-config.hbs"
  ];

  const paths = {};
  for ( const path of partials ) {
    paths[path.replace(".hbs", ".html")] = path;
    paths[`dnd5e.${path.split("/").pop().replace(".hbs", "")}`] = path;
  }

  return loadTemplates(paths);
}

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

/**
 * A helper that converts the provided object into a series of `data-` entries.
 * @param {object} object   Object to convert into dataset entries.
 * @param {object} options  Handlebars options.
 * @returns {string}
 */
function dataset(object, options) {
  const entries = [];
  for ( let [key, value] of Object.entries(object ?? {}) ) {
    if ( value === undefined ) continue;
    key = key.replace(/[A-Z]+(?![a-z])|[A-Z]/g, (a, b) => (b ? "-" : "") + a.toLowerCase());
    entries.push(`data-${key}="${value}"`);
  }
  return new Handlebars.SafeString(entries.join(" "));
}

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

/**
 * A helper to create a set of <option> elements in a <select> block grouped together
 * in <optgroup> based on the provided categories.
 *
 * @param {SelectChoices} choices          Choices to format.
 * @param {object} [options]
 * @param {boolean} [options.localize]     Should the label be localized?
 * @param {string} [options.blank]         Name for the empty option, if one should be added.
 * @param {string} [options.labelAttr]     Attribute pointing to label string.
 * @param {string} [options.chosenAttr]    Attribute pointing to chosen boolean.
 * @param {string} [options.childrenAttr]  Attribute pointing to array of children.
 * @returns {Handlebars.SafeString}        Formatted option list.
 */
function groupedSelectOptions(choices, options) {
  const localize = options.hash.localize ?? false;
  const blank = options.hash.blank ?? null;
  const labelAttr = options.hash.labelAttr ?? "label";
  const chosenAttr = options.hash.chosenAttr ?? "chosen";
  const childrenAttr = options.hash.childrenAttr ?? "children";

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

  // Create a group
  const group = category => {
    let label = category[labelAttr];
    if ( localize ) game.i18n.localize(label);
    html += `<optgroup label="${label}">`;
    children(category[childrenAttr]);
    html += "</optgroup>";
  };

  // Add children
  const children = children => {
    for ( let [name, child] of Object.entries(children) ) {
      if ( child[childrenAttr] ) group(child);
      else option(name, child[labelAttr], child[chosenAttr] ?? false);
    }
  };

  // Create the options
  let html = "";
  if ( blank !== null ) option("", blank);
  children(choices);
  return new Handlebars.SafeString(html);
}

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

/**
 * A helper that fetch the appropriate item context from root and adds it to the first block parameter.
 * @param {object} context  Current evaluation context.
 * @param {object} options  Handlebars options.
 * @returns {string}
 */
function itemContext(context, options) {
  if ( arguments.length !== 2 ) throw new Error("#dnd5e-itemContext requires exactly one argument");
  if ( foundry.utils.getType(context) === "function" ) context = context.call(this);

  const ctx = options.data.root.itemContext?.[context.id];
  if ( !ctx ) {
    const inverse = options.inverse(this);
    if ( inverse ) return options.inverse(this);
  }

  return options.fn(context, { data: options.data, blockParams: [ctx] });
}

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

/**
 * Conceal a section and display a notice if unidentified.
 * @param {boolean} conceal  Should the section be concealed?
 * @param {object} options   Handlebars options.
 * @returns {string}
 */
function concealSection(conceal, options) {
  let content = options.fn(this);
  if ( !conceal ) return content;

  content = `<div inert>
    ${content}
  </div>
  <div class="unidentified-notice">
      <div>
          <strong>${game.i18n.localize("DND5E.Unidentified.Title")}</strong>
          <p>${game.i18n.localize("DND5E.Unidentified.Notice")}</p>
      </div>
  </div>`;
  return content;
}

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

/**
 * Construct an object from the provided arguments.
 * @param {object} options       Handlebars options.
 * @param {object} options.hash
 * @returns {object}
 */
function makeObject({ hash }) {
  return hash;
}

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

/**
 * Register custom Handlebars helpers used by 5e.
 */
function registerHandlebarsHelpers() {
  Handlebars.registerHelper({
    getProperty: foundry.utils.getProperty,
    "dnd5e-concealSection": concealSection,
    "dnd5e-dataset": dataset,
    "dnd5e-formatCR": formatCR,
    "dnd5e-formatModifier": formatModifier,
    "dnd5e-groupedSelectOptions": groupedSelectOptions,
    "dnd5e-itemContext": itemContext,
    "dnd5e-linkForUuid": (uuid, options) => linkForUuid(uuid, options.hash),
    "dnd5e-numberFormat": (context, options) => formatNumber(context, options.hash),
    "dnd5e-numberParts": (context, options) => formatNumberParts(context, options.hash),
    "dnd5e-object": makeObject,
    "dnd5e-textFormat": formatText
  });
}

/* -------------------------------------------- */
/*  Config Pre-Localization                     */
/* -------------------------------------------- */

/**
 * Storage for pre-localization configuration.
 * @type {object}
 * @private
 */
const _preLocalizationRegistrations = {};

/**
 * Mark the provided config key to be pre-localized during the init stage.
 * @param {string} configKeyPath          Key path within `CONFIG.DND5E` to localize.
 * @param {object} [options={}]
 * @param {string} [options.key]          If each entry in the config enum is an object,
 *                                        localize and sort using this property.
 * @param {string[]} [options.keys=[]]    Array of localization keys. First key listed will be used for sorting
 *                                        if multiple are provided.
 * @param {boolean} [options.sort=false]  Sort this config enum, using the key if set.
 */
function preLocalize(configKeyPath, { key, keys=[], sort=false }={}) {
  if ( key ) keys.unshift(key);
  _preLocalizationRegistrations[configKeyPath] = { keys, sort };
}

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

/**
 * Execute previously defined pre-localization tasks on the provided config object.
 * @param {object} config  The `CONFIG.DND5E` object to localize and sort. *Will be mutated.*
 */
function performPreLocalization(config) {
  for ( const [keyPath, settings] of Object.entries(_preLocalizationRegistrations) ) {
    const target = foundry.utils.getProperty(config, keyPath);
    if ( !target ) continue;
    _localizeObject(target, settings.keys);
    if ( settings.sort ) foundry.utils.setProperty(config, keyPath, sortObjectEntries(target, settings.keys[0]));
  }

  // Localize & sort status effects
  CONFIG.statusEffects.forEach(s => s.name = game.i18n.localize(s.name));
  CONFIG.statusEffects.sort((lhs, rhs) =>
    lhs.order || rhs.order ? (lhs.order ?? Infinity) - (rhs.order ?? Infinity)
      : lhs.name.localeCompare(rhs.name, game.i18n.lang)
  );
}

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

/**
 * Localize the values of a configuration object by translating them in-place.
 * @param {object} obj       The configuration object to localize.
 * @param {string[]} [keys]  List of inner keys that should be localized if this is an object.
 * @private
 */
function _localizeObject(obj, keys) {
  for ( const [k, v] of Object.entries(obj) ) {
    const type = typeof v;
    if ( type === "string" ) {
      obj[k] = game.i18n.localize(v);
      continue;
    }

    if ( type !== "object" ) {
      console.error(new Error(
        `Pre-localized configuration values must be a string or object, ${type} found for "${k}" instead.`
      ));
      continue;
    }
    if ( !keys?.length ) {
      console.error(new Error(
        "Localization keys must be provided for pre-localizing when target is an object."
      ));
      continue;
    }

    for ( const key of keys ) {
      const value = foundry.utils.getProperty(v, key);
      if ( !value ) continue;
      foundry.utils.setProperty(v, key, game.i18n.localize(value));
    }
  }
}

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

/**
 * A cache of already-fetched labels for faster lookup.
 * @type {Record<string, Map<string, string>>}
 */
const _attributeLabelCache = {
  activity: new Map(),
  actor: new Map(),
  item: new Map()
};

/**
 * Convert an attribute path to a human-readable label. Assumes paths are on an actor unless an reference item
 * is provided.
 * @param {string} attr              The attribute path.
 * @param {object} [options]
 * @param {Actor5e} [options.actor]  An optional reference actor.
 * @param {Item5e} [options.item]    An optional reference item.
 * @returns {string|void}
 */
function getHumanReadableAttributeLabel(attr, { actor, item }={}) {
  if ( attr.startsWith("system.") ) attr = attr.slice(7);

  // Check any actor-specific names first.
  if ( attr.startsWith("resources.") && actor ) {
    const key = attr.replace(/\.value$/, "");
    const resource = foundry.utils.getProperty(actor, `system.${key}`);
    if ( resource?.label ) return resource.label;
  }

  if ( (attr === "details.xp.value") && (actor?.type === "npc") ) {
    return game.i18n.localize("DND5E.ExperiencePoints.Value");
  }

  if ( attr.startsWith(".") && actor ) {
    // TODO: Remove `strict: false` when https://github.com/foundryvtt/foundryvtt/issues/11214 is resolved
    // Only necessary when opening the token config for an actor in a compendium
    const item = fromUuidSync(attr, { relative: actor, strict: false });
    return item?.name ?? attr;
  }

  // Check if the attribute is already in cache.
  let label = item ? null : _attributeLabelCache.actor.get(attr);
  if ( label ) return label;
  let name;
  let type = "actor";

  const getSchemaLabel = (attr, type, doc) => {
    if ( doc ) return doc.system.schema.getField(attr)?.label;
    for ( const model of Object.values(CONFIG[type].dataModels) ) {
      const field = model.schema.getField(attr);
      if ( field ) return field.label;
    }
  };

  // Activity labels
  if ( item && attr.startsWith("activities.") ) {
    let [, activityId, ...keyPath] = attr.split(".");
    const activity = item.system.activities?.get(activityId);
    if ( !activity ) return attr;
    attr = keyPath.join(".");
    name = `${item.name}: ${activity.name}`;
    type = "activity";
    if ( _attributeLabelCache.activity.has(attr) ) label = _attributeLabelCache.activity.get(attr);
    else if ( attr === "uses.spent" ) label = "DND5E.Uses";
  }

  // Item labels
  else if ( item ) {
    name = item.name;
    type = "item";
    if ( _attributeLabelCache.item.has(attr) ) label = _attributeLabelCache.item.get(attr);
    else if ( attr === "hd.spent" ) label = "DND5E.HitDice";
    else if ( attr === "uses.spent" ) label = "DND5E.Uses";
    else label = getSchemaLabel(attr, "Item", item);
  }

  // Derived fields.
  else if ( attr === "attributes.init.total" ) label = "DND5E.InitiativeBonus";
  else if ( (attr === "attributes.ac.value") || (attr === "attributes.ac.flat") ) label = "DND5E.ArmorClass";
  else if ( attr === "attributes.spell.dc" ) label = "DND5E.SpellDC";

  // Abilities.
  else if ( attr.startsWith("abilities.") ) {
    const [, key] = attr.split(".");
    label = game.i18n.format("DND5E.AbilityScoreL", { ability: CONFIG.DND5E.abilities[key].label });
  }

  // Skills.
  else if ( attr.startsWith("skills.") ) {
    const [, key] = attr.split(".");
    label = game.i18n.format("DND5E.SkillPassiveScore", { skill: CONFIG.DND5E.skills[key].label });
  }

  // Spell slots.
  else if ( attr.startsWith("spells.") ) {
    const [, key] = attr.split(".");
    if ( !/spell\d+/.test(key) ) label = `DND5E.SpellSlots${key.capitalize()}`;
    else {
      const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
      const level = Number(key.slice(5));
      label = game.i18n.format(`DND5E.SpellSlotsN.${plurals.select(level)}`, { n: level });
    }
  }

  // Currency
  else if ( attr.startsWith("currency.") ) {
    const [, key] = attr.split(".");
    label = CONFIG.DND5E.currencies[key]?.label;
  }

  // Attempt to find the attribute in a data model.
  if ( !label ) label = getSchemaLabel(attr, "Actor", actor);

  if ( label ) {
    label = game.i18n.localize(label);
    _attributeLabelCache[type].set(attr, label);
    if ( name ) label = `${name} ${label}`;
  }

  return label;
}

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

/**
 * Perform pre-localization on the contents of a SchemaField. Necessary because the `localizeSchema` method
 * on `Localization` is private.
 * @param {SchemaField} schema
 * @param {string[]} prefixes
 */
function localizeSchema(schema, prefixes) {
  Localization.localizeDataModel({ schema }, { prefixes });
}

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

/**
 * Split a semi-colon-separated list and clean out any empty entries.
 * @param {string} input
 * @returns {string[]}
 */
function splitSemicolons(input) {
  return input.split(";").map(t => t.trim()).filter(t => t);
}

/* -------------------------------------------- */
/*  Migration                                   */
/* -------------------------------------------- */

/**
 * Synchronize the spells for all Actors in some collection with source data from an Item compendium pack.
 * @param {CompendiumCollection} actorPack      An Actor compendium pack which will be updated
 * @param {CompendiumCollection} spellsPack     An Item compendium pack which provides source data for spells
 * @returns {Promise<void>}
 */
async function synchronizeActorSpells(actorPack, spellsPack) {

  // Load all actors and spells
  const actors = await actorPack.getDocuments();
  const spells = await spellsPack.getDocuments();
  const spellsMap = spells.reduce((obj, item) => {
    obj[item.name] = item;
    return obj;
  }, {});

  // Unlock the pack
  await actorPack.configure({locked: false});

  // Iterate over actors
  SceneNavigation.displayProgressBar({label: "Synchronizing Spell Data", pct: 0});
  for ( const [i, actor] of actors.entries() ) {
    const {toDelete, toCreate} = _synchronizeActorSpells(actor, spellsMap);
    if ( toDelete.length ) await actor.deleteEmbeddedDocuments("Item", toDelete);
    if ( toCreate.length ) await actor.createEmbeddedDocuments("Item", toCreate, {keepId: true});
    console.debug(`${actor.name} | Synchronized ${toCreate.length} spells`);
    SceneNavigation.displayProgressBar({label: actor.name, pct: ((i / actors.length) * 100).toFixed(0)});
  }

  // Re-lock the pack
  await actorPack.configure({locked: true});
  SceneNavigation.displayProgressBar({label: "Synchronizing Spell Data", pct: 100});
}

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

/**
 * A helper function to synchronize spell data for a specific Actor.
 * @param {Actor5e} actor
 * @param {Object<string,Item5e>} spellsMap
 * @returns {{toDelete: string[], toCreate: object[]}}
 * @private
 */
function _synchronizeActorSpells(actor, spellsMap) {
  const spells = actor.itemTypes.spell;
  const toDelete = [];
  const toCreate = [];
  if ( !spells.length ) return {toDelete, toCreate};

  for ( const spell of spells ) {
    const source = spellsMap[spell.name];
    if ( !source ) {
      console.warn(`${actor.name} | ${spell.name} | Does not exist in spells compendium pack`);
      continue;
    }

    // Combine source data with the preparation and uses data from the actor
    const spellData = source.toObject();
    const {preparation, uses, save} = spell.toObject().system;
    Object.assign(spellData.system, {preparation, uses});
    spellData.system.save.dc = save.dc;
    foundry.utils.setProperty(spellData, "_stats.compendiumSource", source.uuid);

    // Record spells to be deleted and created
    toDelete.push(spell.id);
    toCreate.push(spellData);
  }
  return {toDelete, toCreate};
}

var utils = /*#__PURE__*/Object.freeze({
  __proto__: null,
  areKeysPressed: areKeysPressed,
  convertLength: convertLength,
  convertTime: convertTime,
  convertWeight: convertWeight,
  defaultUnits: defaultUnits,
  filteredKeys: filteredKeys,
  formatCR: formatCR,
  formatLength: formatLength,
  formatModifier: formatModifier,
  formatNumber: formatNumber,
  formatNumberParts: formatNumberParts,
  formatRange: formatRange,
  formatText: formatText,
  formatTime: formatTime,
  formatVolume: formatVolume,
  formatWeight: formatWeight,
  getHumanReadableAttributeLabel: getHumanReadableAttributeLabel,
  getPluralRules: getPluralRules,
  getSceneTargets: getSceneTargets,
  getTargetDescriptors: getTargetDescriptors,
  indexFromUuid: indexFromUuid,
  isValidDieModifier: isValidDieModifier,
  isValidUnit: isValidUnit,
  linkForUuid: linkForUuid,
  localizeSchema: localizeSchema,
  log: log,
  parseInputDelta: parseInputDelta,
  performPreLocalization: performPreLocalization,
  preLocalize: preLocalize,
  preloadHandlebarsTemplates: preloadHandlebarsTemplates,
  prepareFormulaValue: prepareFormulaValue,
  registerHandlebarsHelpers: registerHandlebarsHelpers,
  replaceFormulaData: replaceFormulaData,
  safePropertyExists: safePropertyExists,
  simplifyBonus: simplifyBonus,
  sortObjectEntries: sortObjectEntries,
  splitSemicolons: splitSemicolons,
  staticID: staticID,
  synchronizeActorSpells: synchronizeActorSpells,
  validators: validators
});

/**
 * @typedef {StringFieldOptions} FormulaFieldOptions
 * @property {boolean} [deterministic=false]  Is this formula not allowed to have dice values?
 */

/**
 * Special case StringField which represents a formula.
 *
 * @param {FormulaFieldOptions} [options={}]  Options which configure the behavior of the field.
 * @property {boolean} deterministic=false    Is this formula not allowed to have dice values?
 */
class FormulaField extends foundry.data.fields.StringField {

  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {
      deterministic: false
    });
  }

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

  /** @inheritDoc */
  _validateType(value) {
    const roll = new Roll(value.replace(/@([a-z.0-9_-]+)/gi, "1"));
    roll.evaluateSync({ strict: false });
    if ( this.options.deterministic && !roll.isDeterministic ) throw new Error(`must not contain dice terms: ${value}`);
    super._validateType(value);
  }

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _castChangeDelta(delta) {
    return this._cast(delta).trim();
  }

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

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    if ( !value ) return delta;
    const operator = delta.startsWith("-") ? "-" : "+";
    delta = delta.replace(/^[+-]/, "").trim();
    return `${value} ${operator} ${delta}`;
  }

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

  /** @override */
  _applyChangeMultiply(value, delta, model, change) {
    if ( !value ) return delta;
    const terms = new Roll(value).terms;
    if ( terms.length > 1 ) return `(${value}) * ${delta}`;
    return `${value} * ${delta}`;
  }

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

  /** @override */
  _applyChangeUpgrade(value, delta, model, change) {
    if ( !value ) return delta;
    const terms = new Roll(value).terms;
    if ( (terms.length === 1) && (terms[0].fn === "max") ) return current.replace(/\)$/, `, ${delta})`);
    return `max(${value}, ${delta})`;
  }

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

  /** @override */
  _applyChangeDowngrade(value, delta, model, change) {
    if ( !value ) return delta;
    const terms = new Roll(value).terms;
    if ( (terms.length === 1) && (terms[0].fn === "min") ) return current.replace(/\)$/, `, ${delta})`);
    return `min(${value}, ${delta})`;
  }
}

const { ArrayField: ArrayField$p, EmbeddedDataField: EmbeddedDataField$6, SchemaField: SchemaField$W, StringField: StringField$1c } = foundry.data.fields;

/**
 * Field for holding one or more consumption targets.
 */
class ConsumptionTargetsField extends ArrayField$p {
  constructor(options={}) {
    super(new EmbeddedDataField$6(ConsumptionTargetData), options);
  }
}

/**
 * Embedded data model for storing consumption target data and handling consumption.
 *
 * @property {string} type             Type of consumption (e.g. activity uses, item uses, hit die, spell slot).
 * @property {string} target           Target of the consumption depending on the selected type (e.g. item's ID, hit
 *                                     die denomination, spell slot level).
 * @property {string} value            Formula that determines amount consumed or recovered.
 * @property {object} scaling
 * @property {string} scaling.mode     Scaling mode (e.g. no scaling, scale target amount, scale spell level).
 * @property {string} scaling.formula  Specific scaling formula if not automatically calculated from target's value.
 */
class ConsumptionTargetData extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      type: new StringField$1c(),
      target: new StringField$1c(),
      value: new FormulaField({ initial: "1" }),
      scaling: new SchemaField$W({
        mode: new StringField$1c(),
        formula: new FormulaField()
      })
    };
  }

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

  /**
   * Activity to which this consumption target belongs.
   * @type {Activity}
   */
  get activity() {
    return this.parent;
  }

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

  /**
   * Actor containing this consumption target, if embedded.
   * @type {Actor5e}
   */
  get actor() {
    return this.activity.actor;
  }

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

  /**
   * Should this consumption only be performed during initiative? This will return `true` if consuming activity or item
   * uses and those uses only recover on "combat" periods.
   * @type {boolean}
   */
  get combatOnly() {
    let recovery;
    switch ( this.type ) {
      case "activityUses":
        recovery = this.activity.uses.recovery;
        break;
      case "itemUses":
        recovery = (this.target ? this.actor?.items.get(this.target) : this.item)?.system.uses.recovery;
        break;
      default: return false;
    }
    if ( !recovery?.length ) return false;
    return recovery.every(r => CONFIG.DND5E.limitedUsePeriods[r.period]?.type === "combat");
  }

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

  /**
   * Item to which this consumption target's activity belongs.
   * @type {Item5e}
   */
  get item() {
    return this.activity.item;
  }

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

  /**
   * List of valid targets within the current context.
   * @type {FormSelectOption[]|null}
   */
  get validTargets() {
    const config = CONFIG.DND5E.activityConsumptionTypes[this.type];
    if ( !config?.validTargets || (!this.item.isEmbedded && (config.targetRequiresEmbedded === true)) ) return null;
    return config.validTargets.call(this);
  }

  /* -------------------------------------------- */
  /*  Consumption                                 */
  /* -------------------------------------------- */

  /**
   * Perform consumption according to the target type.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  async consume(config, updates) {
    const typeConfig = CONFIG.DND5E.activityConsumptionTypes[this.type];
    if ( !typeConfig?.consume ) throw new Error("Consumption types must define consumption method.");
    await typeConfig.consume.call(this, config, updates);
  }

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

  /**
   * Prepare consumption updates for "Activity Uses" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeActivityUses(config, updates) {
    const result = await this._usesConsumption(config, {
      uses: this.activity.uses,
      type: game.i18n.format("DND5E.CONSUMPTION.Type.ActivityUses.Warning", {
        activity: this.activity.name, item: this.item.name
      }),
      rolls: updates.rolls,
      delta: { item: this.item.id, keyPath: `system.activities.${this.activity.id}.uses.spent` }
    });
    if ( result ) foundry.utils.mergeObject(updates.activity, { "uses.spent": result.spent });
  }

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

  /**
   * Prepare consumption updates for "Attribute" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeAttribute(config, updates) {
    const keyPath = `system.${this.target}`;
    const cost = (await this.resolveCost({ config, delta: { keyPath }, rolls: updates.rolls })).total;

    if ( !foundry.utils.hasProperty(this.actor, keyPath) ) throw new ConsumptionError(
      game.i18n.format("DND5E.CONSUMPTION.Warning.MissingAttribute", {
        activity: this.activity.name, attribute: this.target, item: this.item.name
      })
    );
    const current = foundry.utils.getProperty(this.actor, keyPath);

    let warningMessage;
    if ( (cost > 0) && !current ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( current < cost ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) throw new ConsumptionError(game.i18n.format(warningMessage, {
      available: formatNumber(current), cost: formatNumber(cost),
      type: game.i18n.format("DND5E.CONSUMPTION.Type.Attribute.Warning", { attribute: this.target })
    }));

    updates.actor[keyPath] = current - cost;
  }

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

  /**
   * Prepare consumption updates for "Hit Dice" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeHitDice(config, updates) {
    const cost = (await this.resolveCost({ config, rolls: updates.rolls })).total;

    const denom = !["smallest", "largest"].includes(this.target) ? this.target : false;
    const validClasses = Object.values(this.actor.classes).filter(cls => {
      return !denom || (cls.system.hd.denomination === denom);
    });
    const total = validClasses.reduce((count, cls) => count + cls.system.hd.value, 0);

    if ( !denom ) validClasses.sort((lhs, rhs) => {
      const sort = lhs.system.hd.denomination.localeCompare(rhs.system.hd.denomination, "en", { numeric: true });
      return (this.target === "smallest") ? sort : sort * -1;
    });

    let warningMessage;
    if ( !validClasses.length ) warningMessage = "DND5E.CONSUMPTION.Warning.MissingHitDice";
    else if ( (cost > 0) && !total ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( total < cost ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) {
      const denomination = !["smallest", "largest"].includes(this.target) ? this.target : "";
      throw new ConsumptionError(game.i18n.format(warningMessage, {
        available: formatNumber(total), cost: formatNumber(cost), denomination,
        type: game.i18n.format("DND5E.CONSUMPTION.Type.HitDice.Warning", { denomination })
      }));
    }

    let toConsume = cost;
    for ( const cls of validClasses ) {
      const available = toConsume > 0 ? cls.system.hd.value : 0;
      const delta = toConsume > 0 ? Math.min(toConsume, available) : Math.max(toConsume, available);
      const itemUpdate = { "system.hd.spent": cls.system.hd.spent + delta };
      if ( delta !== 0 ) {
        const itemIndex = updates.item.findIndex(i => i._id === cls.id);
        if ( itemIndex === -1 ) updates.item.push({ _id: cls.id, ...itemUpdate });
        else foundry.utils.mergeObject(updates.item[itemIndex], itemUpdate);
        toConsume -= delta;
        if ( toConsume === 0 ) break;
      }
    }
  }

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

  /**
   * Prepare consumption updates for "Item Uses" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeItemUses(config, updates) {
    const item = this.target ? this.actor.items.get(this.target) : this.item;
    if ( !item ) throw new ConsumptionError(game.i18n.format("DND5E.CONSUMPTION.Warning.MissingItem", {
      activity: this.activity.name, item: this.item.name
    }));

    const result = await this._usesConsumption(config, {
      uses: item.system.uses,
      type: game.i18n.format("DND5E.CONSUMPTION.Type.ItemUses.Warning", { name: this.item.name }),
      rolls: updates.rolls,
      delta: { item: item.id, keyPath: "system.uses.spent" }
    });
    if ( !result ) return;

    const itemUpdate = {};
    if ( item.system.uses.autoDestroy && (result.spent === item.system.uses.max) ) {
      const newQuantity = item.system.quantity - 1;
      if ( newQuantity === 0 ) {
        updates.delete.push(item.id);
        return;
      } else {
        itemUpdate["system.uses.spent"] = 0;
        itemUpdate["system.quantity"] = newQuantity;
      }
    } else {
      itemUpdate["system.uses.spent"] = result.spent;
    }

    const itemIndex = updates.item.findIndex(i => i._id === item.id);
    if ( itemIndex === -1 ) updates.item.push({ _id: item.id, ...itemUpdate });
    else foundry.utils.mergeObject(updates.item[itemIndex], itemUpdate);
  }

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

  /**
   * Prepare consumption updates for "Material" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeMaterial(config, updates) {
    const item = this.target ? this.actor.items.get(this.target) : this.item;
    if ( !item ) throw new ConsumptionError(game.i18n.format("DND5E.CONSUMPTION.Warning.MissingItem", {
      activity: this.activity.name, item: this.item.name
    }));

    const delta = { item: item.id, keyPath: "system.quantity" };
    const cost = (await this.resolveCost({ config, delta, rolls: updates.rolls })).total;

    let warningMessage;
    if ( cost > 0 && !item.system.quantity ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( cost > item.system.quantity ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) throw new ConsumptionError(game.i18n.format(warningMessage, {
      available: formatNumber(item.system.quantity), cost: formatNumber(cost),
      type: game.i18n.format("DND5E.CONSUMPTION.Type.Material.Warning", { name: item.name })
    }));

    const newQuantity = item.system.quantity - cost;
    if ( (newQuantity === 0) && item.system.uses?.autoDestroy ) {
      updates.delete.push(item.id);
    } else {
      const itemUpdate = { "system.quantity": newQuantity };
      const itemIndex = updates.item.findIndex(i => i._id === item.id);
      if ( itemIndex === -1 ) updates.item.push({ _id: item.id, ...itemUpdate });
      else foundry.utils.mergeObject(updates.item[itemIndex], itemUpdate);
    }
  }

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

  /**
   * Prepare consumption updates for "Spell Slots" consumption type.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {ActivityUsageUpdates} updates     Updates to be performed.
   * @throws ConsumptionError
   */
  static async consumeSpellSlots(config, updates) {
    const levelNumber = Math.clamp(
      this.resolveLevel({ config, rolls: updates.rolls }), 1, Object.keys(CONFIG.DND5E.spellLevels).length - 1
    );
    const keyPath = `system.spells.spell${levelNumber}.value`;
    const cost = (await this.resolveCost({ config, delta: { keyPath }, rolls: updates.rolls })).total;

    // Check to see if enough slots are available at the specified level
    const levelData = this.actor.system.spells?.[`spell${levelNumber}`];
    const newValue = (levelData?.value ?? 0) - cost;
    let warningMessage;
    if ( !levelData?.max ) warningMessage = "DND5E.CONSUMPTION.Warning.MissingSpellSlot";
    else if ( (cost > 0) && !levelData.value ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( newValue < 0 ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) {
      const level = CONFIG.DND5E.spellLevels[levelNumber];
      const type = game.i18n.format("DND5E.CONSUMPTION.Type.SpellSlots.Warning", { level });
      throw new ConsumptionError(game.i18n.format(warningMessage, {
        type, level, cost: formatNumber(cost), available: formatNumber(levelData.value)
      }));
    }

    updates.actor[keyPath] = Math.max(0, newValue);
  }

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

  /**
   * Calculate updates to activity or item uses.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} options
   * @param {UsesData} options.uses            Uses data to consume.
   * @param {string} options.type              Type label to be used in warning messages.
   * @param {BasicRoll[]} options.rolls        Rolls performed as part of the usages.
   * @param {object} [options.delta]           Delta information stored in roll options.
   * @returns {{ spent: number, quantity: number }|null}
   * @internal
   */
  async _usesConsumption(config, { uses, type, rolls, delta }) {
    const cost = (await this.resolveCost({ config, delta, rolls })).total;

    let warningMessage;
    if ( cost > 0 && !uses.value ) warningMessage = "DND5E.CONSUMPTION.Warning.None";
    else if ( cost > uses.value ) warningMessage = "DND5E.CONSUMPTION.Warning.NotEnough";
    if ( warningMessage ) throw new ConsumptionError(
      game.i18n.format(warningMessage, { type, cost: formatNumber(cost), available: formatNumber(uses.value) })
    );

    return { spent: uses.spent + cost };
  }

  /* -------------------------------------------- */
  /*  Consumption Hints                           */
  /* -------------------------------------------- */

  /**
   * Create label and hint text indicating how much of this resource will be consumed/recovered.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  getConsumptionLabels(config, options={}) {
    const typeConfig = CONFIG.DND5E.activityConsumptionTypes[this.type];
    if ( !typeConfig?.consumptionLabels ) return "";
    return typeConfig.consumptionLabels.call(this, config, options);
  }

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

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsActivityUses(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    const uses = this.activity.uses;
    const usesPluralRule = new Intl.PluralRules(game.i18n.lang).select(uses.value);
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.ActivityUses.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.ActivityUses.PromptHint${increaseKey}`,
        {
          cost,
          use: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${pluralRule}`),
          available: formatNumber(uses.value),
          availableUse: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${usesPluralRule}`)
        }
      ),
      warn: simplifiedCost > uses.value
    };
  }

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

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsAttribute(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey } = this._resolveHintCost(config);
    const current = foundry.utils.getProperty(this.actor.system, this.target);
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.Attribute.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.Attribute.PromptHint${increaseKey}`,
        { cost, attribute: this.target, current: formatNumber(current) }
      ),
      warn: simplifiedCost > current
    };
  }

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

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsHitDice(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    let denomination;
    if ( this.target === "smallest" ) denomination = game.i18n.localize("DND5E.ConsumeHitDiceSmallest");
    else if ( this.target === "largest" ) denomination = game.i18n.localize("DND5E.ConsumeHitDiceLargest");
    else denomination = this.target;
    const available = (["smallest", "largest"].includes(this.target)
      ? this.actor.system.attributes?.hd?.value : this.actor.system.attributes?.hd?.bySize?.[this.target]) ?? 0;
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.HitDice.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.HitDice.PromptHint${increaseKey}`,
        {
          cost, denomination: denomination.toLowerCase(),
          die: game.i18n.localize(`DND5E.CONSUMPTION.Type.HitDie.${pluralRule}`),
          available: formatNumber(available)
        }
      ),
      warn: simplifiedCost > available
    };
  }

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

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsItemUses(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    const item = this.actor.items.get(this.target);
    const itemName = item ? item.name : game.i18n.localize("DND5E.CONSUMPTION.Target.ThisItem").toLowerCase();
    const uses = (item ?? this.item).system.uses;
    const usesPluralRule = new Intl.PluralRules(game.i18n.lang).select(uses.value);

    const notes = [];
    let warn = false;
    if ( simplifiedCost > uses.value ) warn = true;
    else if ( (simplifiedCost > 0) && (uses.value - simplifiedCost === 0) && uses.autoDestroy ) notes.push({
      type: "warn",
      message: game.i18n.format("DND5E.CONSUMPTION.Warning.WillDestroy", { item: itemName })
    });

    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.ItemUses.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.ItemUses.PromptHint${increaseKey}`,
        {
          cost,
          use: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${pluralRule}`),
          available: formatNumber(uses.value),
          availableUse: game.i18n.localize(`DND5E.CONSUMPTION.Type.Use.${usesPluralRule}`),
          item: item ? `<em>${itemName}</em>` : itemName
        }
      ),
      notes: consumed ? notes : null,
      warn
    };
  }

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

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsMaterial(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey } = this._resolveHintCost(config);
    const item = this.actor.items.get(this.target);
    const quantity = (item ?? this.item).system.quantity;
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.Material.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.Material.PromptHint${increaseKey}`,
        {
          cost,
          item: item ? `<em>${item.name}</em>` : game.i18n.localize("DND5E.CONSUMPTION.Target.ThisItem").toLowerCase(),
          quantity: formatNumber(quantity)
        }
      ),
      warn: simplifiedCost > quantity
    };
  }

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

  /**
   * Create hint text indicating how much of this resource will be consumed/recovered.
   * @this {ConsumptionTargetData}
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @param {object} [options={}]
   * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
   * @returns {ConsumptionLabels}
   */
  static consumptionLabelsSpellSlots(config, { consumed }={}) {
    const { cost, simplifiedCost, increaseKey, pluralRule } = this._resolveHintCost(config);
    const levelNumber = Math.clamp(this.resolveLevel({ config }), 1, Object.keys(CONFIG.DND5E.spellLevels).length - 1);
    const level = CONFIG.DND5E.spellLevels[levelNumber].toLowerCase();
    const available = this.actor.system.spells?.[`spell${levelNumber}`]?.value ?? 0;
    return {
      label: game.i18n.localize(`DND5E.CONSUMPTION.Type.SpellSlots.Prompt${increaseKey}`),
      hint: game.i18n.format(
        `DND5E.CONSUMPTION.Type.SpellSlots.PromptHint${increaseKey}`,
        {
          cost,
          slot: game.i18n.format(`DND5E.CONSUMPTION.Type.SpellSlot.${pluralRule}`, { level }),
          available: formatNumber(available)
        }
      ),
      warn: simplifiedCost > available
    };
  }

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

  /**
   * Resolve the cost for the consumption hint.
   * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
   * @returns {{ cost: string, simplifiedCost: number, increaseKey: string, pluralRule: string }}
   * @internal
   */
  _resolveHintCost(config) {
    const costRoll = this.resolveCost({ config, evaluate: false });
    let cost = costRoll.isDeterministic
      ? String(costRoll.evaluateSync().total)
      : simplifyRollFormula(costRoll.formula);
    const simplifiedCost = simplifyBonus(cost);
    const isNegative = cost.startsWith("-");
    if ( isNegative ) cost = cost.replace("-", "");
    let pluralRule;
    if ( costRoll.isDeterministic ) pluralRule = new Intl.PluralRules(game.i18n.lang).select(Number(cost));
    else pluralRule = "other";
    return { cost, simplifiedCost, increaseKey: isNegative ? "Increase" : "Decrease", pluralRule };
  }

  /* -------------------------------------------- */
  /*  Valid Targets                               */
  /* -------------------------------------------- */

  /**
   * Generate a list of targets for the "Attribute" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validAttributeTargets() {
    if ( !this.actor ) return [];
    return TokenDocument.implementation.getConsumedAttributes(this.actor.type).map(attr => {
      let group;
      if ( attr.startsWith("abilities.") ) group = game.i18n.localize("DND5E.AbilityScorePl");
      else if ( attr.startsWith("currency.") ) group = game.i18n.localize("DND5E.Currency");
      else if ( attr.startsWith("spells.") ) group = game.i18n.localize("DND5E.CONSUMPTION.Type.SpellSlots.Label");
      else if ( attr.startsWith("attributes.movement.") ) group = game.i18n.localize("DND5E.Speed");
      else if ( attr.startsWith("attributes.senses.") ) group = game.i18n.localize("DND5E.Senses");
      else if ( attr.startsWith("resources.") ) group = game.i18n.localize("DND5E.Resources");
      return { group, value: attr, label: getHumanReadableAttributeLabel(attr, { actor: this.actor }) || attr };
    });
  }

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

  /**
   * Generate a list of targets for the "Hit Dice" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validHitDiceTargets() {
    return [
      { value: "smallest", label: game.i18n.localize("DND5E.ConsumeHitDiceSmallest") },
      ...CONFIG.DND5E.hitDieTypes.map(d => ({ value: d, label: d })),
      { value: "largest", label: game.i18n.localize("DND5E.ConsumeHitDiceLargest") }
    ];
  }

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

  /**
   * Generate a list of targets for the "Item Uses" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validItemUsesTargets() {
    const makeLabel = (name, item) => {
      let label;
      const uses = item.system.uses;
      if ( uses.max && (uses.recovery?.length === 1) && (uses.recovery[0].type === "recoverAll")
        && (uses.recovery[0].period !== "recharge") ) {
        const per = CONFIG.DND5E.limitedUsePeriods[uses.recovery[0].period]?.abbreviation;
        label = game.i18n.format("DND5E.AbilityUseConsumableLabel", { max: uses.max, per });
      }
      else label = game.i18n.format("DND5E.AbilityUseChargesLabel", { value: uses.value });
      return `${name} (${label})`;
    };
    return [
      { value: "", label: makeLabel(game.i18n.localize("DND5E.CONSUMPTION.Target.ThisItem"), this.item) },
      { rule: true },
      ...(this.actor?.items ?? [])
        .filter(i => i.system.uses?.max && (i !== this.item))
        .map(i => ({ value: i.id, label: makeLabel(i.name, i) }))
    ];
  }

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

  /**
   * Generate a list of targets for the "Material" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validMaterialTargets() {
    return (this.actor?.items ?? [])
      .filter(i => ["consumable", "loot"].includes(i.type) && !i.system.activities?.size)
      .map(i => ({ value: i.id, label: `${i.name} (${formatNumber(i.system.quantity)})` }));
  }

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

  /**
   * Generate a list of targets for the "Spell Slots" consumption type.
   * @this {ConsumptionTargetData}
   * @returns {FormSelectOption[]}
   */
  static validSpellSlotsTargets() {
    return Object.entries(CONFIG.DND5E.spellLevels).reduce((arr, [value, label]) => {
      if ( value !== "0" ) arr.push({ value, label });
      return arr;
    }, []);
  }

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

  /**
   * Resolve the amount to consume, taking scaling into account.
   * @param {object} [options={}]
   * @param {ActivityUseConfiguration} [options.config]  Usage configuration.
   * @param {boolean} [options.evaluate=true]            Should the cost roll be evaluated?
   * @param {BasicRoll[]} [options.rolls]                Rolls performed as part of the usages.
   * @returns {Promise<BasicRoll>|BasicRoll}             Returns Promise if evaluate is `true`.
   */
  resolveCost({ config={}, ...options }={}) {
    return this._resolveScaledRoll(this.value, this.scaling.mode === "amount" ? config.scaling ?? 0 : 0, options);
  }

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

  /**
   * Resolve the spell level to consume, taking scaling into account.
   * @param {object} [options={}]
   * @param {ActivityUseConfiguration} [options.config]  Usage configuration.
   * @param {BasicRoll[]} [options.rolls]                Rolls performed as part of the usages.
   * @returns {number}
   */
  resolveLevel({ config={}, ...options }={}) {
    const roll = this._resolveScaledRoll(
      this.target, this.scaling.mode === "level" ? config.scaling ?? 0 : 0, { ...options, evaluate: false }
    );
    roll.evaluateSync();
    return roll.total;
  }

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

  /**
   * Resolve a scaling consumption value formula.
   * @param {string} formula                   Formula for the initial value.
   * @param {number} scaling                   Amount to scale the formula.
   * @param {object} [options={}]
   * @param {object} [options.delta]           Delta information stored in roll options.
   * @param {boolean} [options.evaluate=true]  Should the slot roll be evaluated?
   * @param {BasicRoll[]} [options.rolls]      Rolls performed as part of the usages.
   * @returns {Promise<BasicRoll>|BasicRoll}
   * @internal
   */
  _resolveScaledRoll(formula, scaling, { delta, evaluate=true, rolls }={}) {
    const rollData = this.activity.getRollData();
    const roll = new CONFIG.Dice.BasicRoll(formula, rollData, { delta });

    if ( scaling ) {
      // If a scaling formula is provided, multiply it and add to the end of the initial formula
      if ( this.scaling.formula ) {
        const scalingRoll = new Roll(this.scaling.formula, rollData);
        scalingRoll.alter(scaling, undefined, { multiplyNumeric: true });
        roll.terms.push(new foundry.dice.terms.OperatorTerm({ operator: "+" }), ...scalingRoll.terms);
      }

      // Otherwise increase the number of dice and the numeric term for each scaling step
      else roll.terms = roll.terms.map(term => {
        if ( term instanceof foundry.dice.terms.DiceTerm ) return term.alter(undefined, scaling);
        else if ( term instanceof foundry.dice.terms.NumericTerm ) term.number += scaling;
        return term;
      });

      roll.resetFormula();
    }

    if ( evaluate ) return roll.evaluate().then(roll => {
      if ( rolls && !roll.isDeterministic ) rolls.push(roll);
      return roll;
    });
    if ( rolls && !roll.isDeterministic ) rolls.push(roll);
    return roll;
  }
}

/**
 * Error to throw when consumption cannot be achieved.
 */
class ConsumptionError extends Error {
  constructor(...args) {
    super(...args);
    this.name = "ConsumptionError";
  }
}

const { HandlebarsApplicationMixin } = foundry.applications.api;

/**
 * @typedef {ApplicationContainerParts}
 * @property {object} [container]
 * @property {string} [container.id]         ID of the container. Containers with the same ID will be grouped together.
 * @property {string[]} [container.classes]  Classes to add to the container.
 */

/**
 * Mixin method for ApplicationV2-based 5e applications.
 * @template {ApplicationV2} T
 * @param {typeof T} Base   Application class being extended.
 * @returns {typeof BaseApplication5e}
 * @mixin
 */
function ApplicationV2Mixin(Base) {
  class BaseApplication5e extends HandlebarsApplicationMixin(Base) {
    /** @override */
    static DEFAULT_OPTIONS = {
      classes: ["dnd5e2"],
      window: {
        subtitle: ""
      }
    };

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

    /**
     * @type {Record<string, HandlebarsTemplatePart & ApplicationContainerParts>}
     */
    static PARTS = {};

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

    /**
     * A reference to the window subtitle.
     * @type {string}
     */
    get subtitle() {
      return game.i18n.localize(this.options.window.subtitle ?? "");
    }

    /* -------------------------------------------- */
    /*  Initialization                              */
    /* -------------------------------------------- */

    /** @inheritDoc */
    _initializeApplicationOptions(options) {
      const applicationOptions = super._initializeApplicationOptions(options);
      // Fix focus bug caused by the use of UUIDs in application IDs
      // TODO: Remove once https://github.com/foundryvtt/foundryvtt/issues/11742 is fixed
      applicationOptions.uniqueId = applicationOptions.uniqueId.replace(/\./g, "-");
      return applicationOptions;
    }

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

    /** @inheritDoc */
    _configureRenderOptions(options) {
      super._configureRenderOptions(options);
      if ( options.isFirstRender && this.hasFrame ) {
        options.window ||= {};
        options.window.subtitle ||= this.subtitle;
      }
    }

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

    /** @inheritDoc */
    _onFirstRender(context, options) {
      super._onFirstRender(context, options);
      const containers = {};
      for ( const [part, config] of Object.entries(this.constructor.PARTS) ) {
        if ( !config.container?.id ) continue;
        const element = this.element.querySelector(`[data-application-part="${part}"]`);
        if ( !element ) continue;
        if ( !containers[config.container.id] ) {
          const div = document.createElement("div");
          div.dataset.containerId = config.container.id;
          div.classList.add(...config.container.classes ?? []);
          containers[config.container.id] = div;
          element.replaceWith(div);
        }
        containers[config.container.id].append(element);
      }
    }

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

    /** @inheritDoc */
    async _prepareContext(options) {
      const context = await super._prepareContext(options);
      context.CONFIG = CONFIG.DND5E;
      context.inputs = { ...foundry.applications.fields, ...dnd5e.applications.fields };
      return context;
    }

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

    /** @inheritDoc */
    async _preparePartContext(partId, context, options) {
      return { ...await super._preparePartContext(partId, context, options) };
    }

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

    /** @inheritDoc */
    async _renderFrame(options) {
      const frame = await super._renderFrame(options);

      // Subtitles
      const subtitle = document.createElement("h2");
      subtitle.classList.add("window-subtitle");
      frame?.querySelector(".window-title")?.insertAdjacentElement("afterend", subtitle);

      // Icon
      if ( (options.window?.icon ?? "").includes(".") ) {
        const icon = frame.querySelector(".window-icon");
        const newIcon = document.createElement(options.window.icon?.endsWith(".svg") ? "dnd5e-icon" : "img");
        newIcon.classList.add("window-icon");
        newIcon.src = options.window.icon;
        icon.replaceWith(newIcon);
      }

      return frame;
    }

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

    /** @inheritDoc */
    _updateFrame(options) {
      super._updateFrame(options);
      if ( options.window && ("subtitle" in options.window) ) {
        this.element.querySelector(".window-header > .window-subtitle").innerText = options.window.subtitle;
      }
    }

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

    /** @inheritDoc */
    _onRender(context, options) {
      super._onRender(context, options);

      // Allow multi-select tags to be removed when the whole tag is clicked.
      this.element.querySelectorAll("multi-select").forEach(select => {
        if ( select.disabled ) return;
        select.querySelectorAll(".tag").forEach(tag => {
          tag.classList.add("remove");
          tag.querySelector(":scope > span")?.classList.add("remove");
        });
      });

      // Add special styling for label-top hints.
      this.element.querySelectorAll(".label-top > p.hint").forEach(hint => {
        const label = hint.parentElement.querySelector(":scope > label");
        if ( !label ) return;
        hint.ariaLabel = hint.innerText;
        hint.dataset.tooltip = hint.innerHTML;
        hint.innerHTML = "";
        label.insertAdjacentElement("beforeend", hint);
      });
    }

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

    /**
     * Disable form fields that aren't marked with the `interface-only` class.
     */
    _disableFields() {
      const selector = `.window-content :is(${[
        "INPUT", "SELECT", "TEXTAREA", "BUTTON", "DND5E-CHECKBOX", "COLOR-PICKER", "DOCUMENT-TAGS",
        "FILE-PICKER", "HUE-SLIDER", "MULTI-SELECT", "PROSE-MIRROR", "RANGE-PICKER", "STRING-TAGS"
      ].join(", ")}):not(.interface-only)`;
      for ( const element of this.element.querySelectorAll(selector) ) {
        if ( element.tagName === "TEXTAREA" ) element.readOnly = true;
        else element.disabled = true;
      }
    }
  }
  return BaseApplication5e;
}

const { ApplicationV2 } = foundry.applications.api;

/**
 * Base application from which all system applications should be based.
 */
class Application5e extends ApplicationV2Mixin(ApplicationV2) {}

/**
 * Default sheet for activities.
 */
class PseudoDocumentSheet extends Application5e {
  constructor(options={}) {
    super(options);
    this.#documentId = options.document.id;
    this.#documentType = options.document.metadata.name;
    this.#item = options.document.item;
  }

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

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["pseudo-document", "sheet", "standard-form"],
    tag: "form",
    document: null,
    viewPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED,
    editPermission: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
    actions: {
      copyUuid: { handler: PseudoDocumentSheet.#onCopyUuid, buttons: [0, 2] }
    },
    form: {
      handler: PseudoDocumentSheet.#onSubmitForm,
      submitOnChange: true
    }
  };

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

  /**
   * The PseudoDocument associated with this application.
   * @type {PseudoDocument}
   */
  get document() {
    return this.item.getEmbeddedDocument(this.#documentType, this.#documentId);
  }

  /**
   * ID of this PseudoDocument on the parent item.
   * @type {string}
   */
  #documentId;

  /**
   * Collection representing this PseudoDocument.
   * @type {string}
   */
  #documentType;

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

  /**
   * Is this PseudoDocument sheet visible to the current user?
   * @type {boolean}
   */
  get isVisible() {
    return this.item.testUserPermission(game.user, this.options.viewPermission);
  }

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

  /**
   * Is this PseudoDocument sheet editable by the current User?
   * This is governed by the editPermission threshold configured for the class.
   * @type {boolean}
   */
  get isEditable() {
    if ( game.packs.get(this.item.pack)?.locked ) return false;
    return this.item.testUserPermission(game.user, this.options.editPermission);
  }

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

  /**
   * Parent item to which this PseudoDocument belongs.
   * @type {Item5e}
   */
  #item;

  get item() {
    return this.#item;
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    return {
      ...await super._prepareContext(options),
      document: this.document,
      editable: this.isEditable,
      options: this.options
    };
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @override */
  _canRender(options) {
    if ( !this.isVisible ) throw new Error(game.i18n.format("SHEETS.DocumentSheetPrivate", {
      type: game.i18n.localize(this.document.metadata.label)
    }));
  }

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

  /** @inheritDoc */
  _onFirstRender(context, options) {
    super._onFirstRender(context, options);
    this.document.constructor._registerApp(this.document, this);
    this.item.apps[this.id] = this;
  }

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

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    if ( !this.isEditable ) this._disableFields();
  }

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

  /** @override */
  _onClose(_options) {
    this.document?.constructor._unregisterApp(this.document, this);
    delete this.item.apps[this.id];
  }

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

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    frame.autocomplete = "off";

    // Add document ID copy
    const copyLabel = game.i18n.localize("SHEETS.CopyUuid");
    const copyId = `<button type="button" class="header-control fa-solid fa-passport icon" data-action="copyUuid"
                            data-tooltip="${copyLabel}" aria-label="${copyLabel}"></button>`;
    this.window.close.insertAdjacentHTML("beforebegin", copyId);

    return frame;
  }

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

  /**
   * Handle click events to copy the UUID of this document to clipboard.
   * @this {PseudoDocumentSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @this {PseudoDocumentSheet}
   */
  static #onCopyUuid(event, target) {
    event.preventDefault();
    event.stopPropagation();
    if ( event.detail > 1 ) return;
    const id = event.button === 2 ? this.document.id : this.document.uuid;
    const type = event.button === 2 ? "id" : "uuid";
    const label = game.i18n.localize(this.document.metadata.label);
    game.clipboard.copyPlainText(id);
    ui.notifications.info(game.i18n.format("DOCUMENT.IdCopiedClipboard", { label, type, id }));
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /**
   * Handle form submission.
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {HTMLFormElement} form       The form that was submitted.
   * @param {FormDataExtended} formData  Data from the submitted form.
   */
  static async #onSubmitForm(event, form, formData) {
    const submitData = this._prepareSubmitData(event, formData);
    await this._processSubmitData(event, submitData);
  }

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

  /**
   * Perform any pre-processing of the form data to prepare it for updating.
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {FormDataExtended} formData  Data from the submitted form.
   * @returns {object}
   */
  _prepareSubmitData(event, formData) {
    const submitData = foundry.utils.expandObject(formData.object);
    // Workaround for https://github.com/foundryvtt/foundryvtt/issues/11610
    this.element.querySelectorAll("fieldset legend :is(input, select, dnd5e-checkbox)").forEach(input => {
      foundry.utils.setProperty(submitData, input.name, input.value);
    });
    return submitData;
  }

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

  /**
   * Handle updating the PseudoDocument based on processed submit data.
   * @param {SubmitEvent} event  Triggering submit event.
   * @param {object} submitData  Prepared object for updating.
   */
  async _processSubmitData(event, submitData) {
    await this.document.update(submitData);
  }

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

  /**
   * Programmatically submit a PseudoDocumentSheet instance, providing additional data to be merged with form data.
   * @param {object} options
   * @param {object} [options.updateData]  Additional data merged with processed form data.
   */
  async submit({ updateData={} }={}) {
    if ( !this.options.form?.handler ) throw new Error(
      `The ${this.constructor.name} PseudoDocumentSheet does not support a single top-level form element.`
    );
    const event = new Event("submit", { cancelable: true });
    const formData = new FormDataExtended(this.element);
    const submitData = await this._prepareSubmitData(event, formData);
    foundry.utils.mergeObject(submitData, updateData, { inplace: true });
    await this._processSubmitData(event, submitData);
  }
}

/**
 * Default sheet for activities.
 */
class ActivitySheet extends PseudoDocumentSheet {
  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["activity"],
    window: {
      icon: "fa-solid fa-gauge"
    },
    actions: {
      addConsumption: ActivitySheet.#addConsumption,
      addDamagePart: ActivitySheet.#addDamagePart,
      addEffect: ActivitySheet.#addEffect,
      addRecovery: ActivitySheet.#addRecovery,
      deleteConsumption: ActivitySheet.#deleteConsumption,
      deleteDamagePart: ActivitySheet.#deleteDamagePart,
      deleteEffect: ActivitySheet.#deleteEffect,
      deleteRecovery: ActivitySheet.#deleteRecovery,
      dissociateEffect: ActivitySheet.#dissociateEffect,
      toggleCollapsed: ActivitySheet.#toggleCollapsed
    },
    position: {
      width: 500,
      height: "auto"
    }
  };

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

  /** @override */
  static PARTS = {
    tabs: {
      template: "templates/generic/tab-navigation.hbs"
    },
    identity: {
      template: "systems/dnd5e/templates/activity/identity.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-identity.hbs"
      ]
    },
    activation: {
      template: "systems/dnd5e/templates/activity/activation.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-time.hbs",
        "systems/dnd5e/templates/activity/parts/activity-targeting.hbs",
        "systems/dnd5e/templates/activity/parts/activity-consumption.hbs"
      ]
    },
    effect: {
      template: "systems/dnd5e/templates/activity/effect.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-effects.hbs"
      ]
    }
  };

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

  /**
   * Key paths to the parts of the submit data stored in arrays that will need special handling on submission.
   * @type {string[]}
   */
  static CLEAN_ARRAYS = ["consumption.targets", "damage.parts", "effects", "uses.recovery"];

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

  /** @override */
  tabGroups = {
    sheet: "identity",
    activation: "time"
  };

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

  /**
   * The Activity associated with this application.
   * @type {Activity}
   */
  get activity() {
    return this.document;
  }

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

  /**
   * Expanded states for additional settings sections.
   * @type {Map<string, boolean>}
   */
  #expandedSections = new Map();

  get expandedSections() {
    return this.#expandedSections;
  }

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

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

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

  /** @inheritDoc */
  async _prepareContext(options) {
    return {
      ...await super._prepareContext(options),
      activity: this.activity,
      fields: this.activity.schema.fields,
      inferred: this.activity._inferredSource,
      source: this.activity.toObject(),
      tabs: this._getTabs()
    };
  }

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

  /** @override */
  async _preparePartContext(partId, context) {
    switch ( partId ) {
      case "activation": return this._prepareActivationContext(context);
      case "effect": return this._prepareEffectContext(context);
      case "identity": return this._prepareIdentityContext(context);
    }
    return context;
  }

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

  /**
   * Prepare rendering context for the activation tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareActivationContext(context) {
    context.tab = context.tabs.activation;

    context.data = {};
    context.disabled = {};
    for ( const field of ["activation", "duration", "range", "target", "uses"] ) {
      if ( !this.activity[field] ) continue;
      context.data[field] = this.activity[field].override ? context.source[field] : context.inferred[field];
      context.disabled[field] = this.activity[field].canOverride && !this.activity[field].override;
    }

    context.activationTypes = [
      ...Object.entries(CONFIG.DND5E.activityActivationTypes).map(([value, config]) => ({
        value,
        label: game.i18n.localize(config.label),
        group: game.i18n.localize(config.group)
      })),
      { value: "", label: game.i18n.localize("DND5E.NoneActionLabel") }
    ];
    context.affectsPlaceholder = game.i18n.localize(
      `DND5E.TARGET.Count.${context.data.target?.template?.type ? "Every" : "Any"}`
    );
    context.durationUnits = [
      { value: "inst", label: game.i18n.localize("DND5E.TimeInst") },
      ...Object.entries(CONFIG.DND5E.scalarTimePeriods).map(([value, label]) => ({
        value, label, group: game.i18n.localize("DND5E.DurationTime")
      })),
      ...Object.entries(CONFIG.DND5E.permanentTimePeriods).map(([value, label]) => ({
        value, label, group: game.i18n.localize("DND5E.DurationPermanent")
      })),
      { value: "spec", label: game.i18n.localize("DND5E.Special") }
    ];
    context.rangeUnits = [
      ...Object.entries(CONFIG.DND5E.rangeTypes).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => ({
        value, label, group: game.i18n.localize("DND5E.RangeDistance")
      }))
    ];

    // Consumption targets
    const canScale = this.activity.canConfigureScaling;
    const consumptionTypeOptions = Array.from(this.activity.validConsumptionTypes).map(value => ({
      value,
      label: CONFIG.DND5E.activityConsumptionTypes[value].label
    }));
    context.consumptionTargets = context.source.consumption.targets.map((data, index) => {
      const typeConfig = CONFIG.DND5E.activityConsumptionTypes[data.type] ?? {};
      const showTextTarget = typeConfig.targetRequiresEmbedded && !this.item.isEmbedded;
      const target = new ConsumptionTargetData(data, { parent: this.activity });
      return {
        data,
        fields: this.activity.schema.fields.consumption.fields.targets.element.fields,
        prefix: `consumption.targets.${index}.`,
        source: context.source.consumption.targets[index] ?? data,
        typeOptions: consumptionTypeOptions,
        scalingModes: canScale ? [
          { value: "", label: game.i18n.localize("DND5E.CONSUMPTION.Scaling.None") },
          { value: "amount", label: game.i18n.localize("DND5E.CONSUMPTION.Scaling.Amount") },
          ...(typeConfig.scalingModes ?? []).map(({ value, label }) => ({ value, label: game.i18n.localize(label) }))
        ] : null,
        showTargets: "validTargets" in typeConfig,
        targetPlaceholder: data.type === "itemUses" ? game.i18n.localize("DND5E.CONSUMPTION.Target.ThisItem") : null,
        validTargets: showTextTarget ? null : target.validTargets
      };
    });
    context.showConsumeSpellSlot = this.activity.isSpell && (this.item.system.level !== 0);
    context.showScaling = !this.activity.isSpell;

    // Uses recovery
    context.recoveryPeriods = CONFIG.DND5E.limitedUsePeriods.recoveryOptions;
    context.recoveryTypes = [
      { value: "recoverAll", label: game.i18n.localize("DND5E.USES.Recovery.Type.RecoverAll") },
      { value: "loseAll", label: game.i18n.localize("DND5E.USES.Recovery.Type.LoseAll") },
      { value: "formula", label: game.i18n.localize("DND5E.USES.Recovery.Type.Formula") }
    ];
    context.usesRecovery = context.source.uses.recovery.map((data, index) => ({
      data,
      fields: this.activity.schema.fields.uses.fields.recovery.element.fields,
      prefix: `uses.recovery.${index}.`,
      source: context.source.uses.recovery[index] ?? data,
      formulaOptions: data.period === "recharge" ? Array.fromRange(5, 2).reverse().map(min => ({
        value: min,
        label: game.i18n.format("DND5E.USES.Recovery.Recharge.Range", {
          range: min === 6 ? formatNumber(6) : formatRange(min, 6)
        })
      })) : null
    }));

    // Template dimensions
    context.dimensions = context.activity.target?.template?.dimensions;

    return context;
  }

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

  /**
   * Prepare a specific applied effect if present in the activity data.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {object} effect                     Applied effect context being prepared.
   * @returns {object}
   * @protected
   */
  _prepareAppliedEffectContext(context, effect) {
    return effect;
  }

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

  /**
   * Prepare a specific damage part if present in the activity data.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {object} part                       Damage part context being prepared.
   * @returns {object}
   * @protected
   */
  _prepareDamagePartContext(context, part) {
    return part;
  }

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

  /**
   * Prepare rendering context for the effect tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareEffectContext(context) {
    context.tab = context.tabs.effect;

    if ( context.activity.effects ) {
      const appliedEffects = new Set(context.activity.effects?.map(e => e._id) ?? []);
      context.allEffects = this.item.effects
        .filter(e => e.type !== "enchantment")
        .map(effect => ({
          value: effect.id, label: effect.name, selected: appliedEffects.has(effect.id)
        }));
      context.appliedEffects = context.activity.effects.reduce((arr, data) => {
        if ( !data.effect ) return arr;
        const effect = {
          data,
          collapsed: this.expandedSections.get(`effects.${data._id}`) ? "" : "collapsed",
          effect: data.effect,
          fields: this.activity.schema.fields.effects.element.fields,
          prefix: `effects.${data._index}.`,
          source: context.source.effects[data._index] ?? data,
          contentLink: data.effect.toAnchor().outerHTML,
          additionalSettings: null
        };
        arr.push(this._prepareAppliedEffectContext(context, effect));
        return arr;
      }, []);
    }

    context.denominationOptions = [
      { value: "", label: "" },
      ...CONFIG.DND5E.dieSteps.map(value => ({ value, label: `d${value}` }))
    ];
    if ( context.activity.damage?.parts ) {
      const scaleKey = (this.item.type === "spell") && (this.item.system.level === 0) ? "labelCantrip" : "label";
      const scalingOptions = [
        { value: "", label: game.i18n.localize("DND5E.DAMAGE.Scaling.None") },
        ...Object.entries(CONFIG.DND5E.damageScalingModes).map(([value, { [scaleKey]: label }]) => ({ value, label }))
      ];
      const typeOptions = Object.entries(CONFIG.DND5E.damageTypes).map(([value, { label }]) => ({ value, label }));
      const makePart = (data, index) => this._prepareDamagePartContext(context, {
        data, index, scalingOptions, typeOptions,
        canScale: this.activity.canScaleDamage,
        fields: this.activity.schema.fields.damage.fields.parts.element.fields,
        prefix: index !== undefined ? `damage.parts.${index}.` : "_.",
        source: data
      });
      context.damageParts = [
        ...context.activity.damage.parts
          .filter(p => p._index === undefined)
          .map((data, index) => makePart(data)),
        ...context.source.damage.parts.map((data, index) => makePart(data, index))
      ];
    }

    return context;
  }

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

  /**
   * Prepare rendering context for the identity tab.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {ApplicationRenderContext}
   * @protected
   */
  async _prepareIdentityContext(context) {
    context.tab = context.tabs.identity;
    context.placeholder = {
      name: game.i18n.localize(this.activity.metadata.title),
      img: this.activity.metadata.img
    };
    return context;
  }

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

  /**
   * Prepare the tab information for the sheet.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @protected
   */
  _getTabs() {
    return this._markTabs({
      identity: {
        id: "identity", group: "sheet", icon: "fa-solid fa-tag",
        label: "DND5E.ACTIVITY.SECTIONS.Identity"
      },
      activation: {
        id: "activation", group: "sheet", icon: "fa-solid fa-clapperboard",
        label: "DND5E.ACTIVITY.SECTIONS.Activation",
        tabs: {
          time: {
            id: "time", group: "activation", icon: "fa-solid fa-clock",
            label: "DND5E.ACTIVITY.SECTIONS.Time"
          },
          consumption: {
            id: "consumption", group: "activation", icon: "fa-solid fa-boxes-stacked",
            label: "DND5E.CONSUMPTION.FIELDS.consumption.label"
          },
          targeting: {
            id: "activation-targeting", group: "activation", icon: "fa-solid fa-bullseye",
            label: "DND5E.TARGET.FIELDS.target.label"
          }
        }
      },
      effect: {
        id: "effect", group: "sheet", icon: "fa-solid fa-sun",
        label: "DND5E.ACTIVITY.SECTIONS.Effect"
      }
    });
  }

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

  /**
   * Helper to mark the tabs data structure with the appropriate CSS class if it is active.
   * @param {Record<string, Partial<ApplicationTab>>} tabs  Tabs definition to modify.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @internal
   */
  _markTabs(tabs) {
    for ( const v of Object.values(tabs) ) {
      v.active = this.tabGroups[v.group] === v.id;
      v.cssClass = v.active ? "active" : "";
      if ( "tabs" in v ) this._markTabs(v.tabs);
    }
    return tabs;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    for ( const element of this.element.querySelectorAll("[data-expand-id]") ) {
      element.querySelector(".collapsible")?.classList
        .toggle("collapsed", !this.#expandedSections.get(element.dataset.expandId));
    }
    this.#toggleNestedTabs();
  }

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

  /** @inheritDoc */
  changeTab(tab, group, options={}) {
    super.changeTab(tab, group, options);
    if ( group !== "sheet" ) return;
    this.#toggleNestedTabs();
  }

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

  /**
   * Apply nested tab classes.
   */
  #toggleNestedTabs() {
    const primary = this.element.querySelector('.window-content > [data-application-part="tabs"]');
    const active = this.element.querySelector('.tab.active[data-group="sheet"]');
    if ( !primary || !active ) return;
    primary.classList.toggle("nested-tabs", active.querySelector(":scope > .sheet-tabs"));
  }

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

  /**
   * Handle adding a new entry to the consumption list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addConsumption(event, target) {
    const types = this.activity.validConsumptionTypes;
    const existingTypes = new Set(this.activity.consumption.targets.map(t => t.type));
    const filteredTypes = types.difference(existingTypes);
    this.activity.update({
      "consumption.targets": [
        ...this.activity.toObject().consumption.targets,
        { type: filteredTypes.first() ?? types.first() }
      ]
    });
  }

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

  /**
   * Handle adding a new entry to the damage parts list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addDamagePart(event, target) {
    if ( !this.activity.damage?.parts ) return;
    this.activity.update({ "damage.parts": [...this.activity.toObject().damage.parts, {}] });
  }

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

  /**
   * Handle creating a new active effect and adding it to the applied effects list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #addEffect(event, target) {
    if ( !this.activity.effects ) return;
    const effectData = this._addEffectData();
    const [created] = await this.item.createEmbeddedDocuments("ActiveEffect", [effectData], { render: false });
    this.activity.update({ effects: [...this.activity.toObject().effects, { _id: created.id }] });
  }

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

  /**
   * The data for a newly created applied effect.
   * @returns {object}
   * @protected
   */
  _addEffectData() {
    return {
      name: this.item.name,
      img: this.item.img,
      origin: this.item.uuid,
      transfer: false
    };
  }

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

  /**
   * Handle adding a new entry to the uses recovery list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addRecovery(event, target) {
    const periods = new Set(
      Object.entries(CONFIG.DND5E.limitedUsePeriods).filter(([, config]) => !config.deprecated).map(([k]) => k)
    );
    const existingPeriods = new Set(this.activity.uses.recovery.map(t => t.period));
    const filteredPeriods = periods.difference(existingPeriods);
    this.activity.update({
      "uses.recovery": [
        ...this.activity.toObject().uses.recovery,
        { period: filteredPeriods.first() ?? periods.first() }
      ]
    });
  }

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

  /**
   * Handle removing an entry from the consumption targets list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteConsumption(event, target) {
    const consumption = this.activity.toObject().consumption.targets;
    consumption.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ "consumption.targets": consumption });
  }

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

  /**
   * Handle removing an entry from the damage parts list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteDamagePart(event, target) {
    if ( !this.activity.damage?.parts ) return;
    const parts = this.activity.toObject().damage.parts;
    parts.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ "damage.parts": parts });
  }

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

  /**
   * Handle deleting an active effect and removing it from the applied effects list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #deleteEffect(event, target) {
    if ( !this.activity.effects ) return;
    const effectId = target.closest("[data-effect-id]")?.dataset.effectId;
    const result = await this.item.effects.get(effectId)?.deleteDialog({}, { render: false });
    if ( result instanceof ActiveEffect ) {
      const effects = this.activity.toObject().effects.filter(e => e._id !== effectId);
      this.activity.update({ effects });
    }
  }

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

  /**
   * Handle removing an entry from the uses recovery list.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteRecovery(event, target) {
    const recovery = this.activity.toObject().uses.recovery;
    recovery.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ "uses.recovery": recovery });
  }

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

  /**
   * Handle dissociating an Active Effect from this Activity.
   * @this {ActivitySheet}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   */
  static #dissociateEffect(event, target) {
    const { effectId } = target.closest("[data-effect-id]")?.dataset ?? {};
    if ( !this.activity.effects || !effectId ) return;
    const effects = this.activity.toObject().effects.filter(e => e._id !== effectId);
    this.activity.update({ effects });
  }

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

  /**
   * Handle toggling the collapsed state of an additional settings section.
   * @this {ActivitySheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #toggleCollapsed(event, target) {
    if ( event.target.closest(".collapsible-content") ) return;
    target.classList.toggle("collapsed");
    this.#expandedSections.set(
      target.closest("[data-expand-id]")?.dataset.expandId,
      !target.classList.contains("collapsed")
    );
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /**
   * Perform any pre-processing of the form data to prepare it for updating.
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {FormDataExtended} formData  Data from the submitted form.
   * @returns {object}
   */
  _prepareSubmitData(event, formData) {
    const submitData = super._prepareSubmitData(event, formData);
    for ( const keyPath of this.constructor.CLEAN_ARRAYS ) {
      const data = foundry.utils.getProperty(submitData, keyPath);
      if ( data ) foundry.utils.setProperty(submitData, keyPath, Object.values(data));
    }
    if ( foundry.utils.hasProperty(submitData, "appliedEffects") ) {
      const effects = submitData.effects ?? this.activity.toObject().effects;
      submitData.effects = effects.filter(e => submitData.appliedEffects.includes(e._id));
      for ( const _id of submitData.appliedEffects ) {
        if ( submitData.effects.find(e => e._id === _id) ) continue;
        submitData.effects.push({ _id });
      }
    }
    return submitData;
  }
}

/**
 * Application for creating dnd5e dialogs.
 */
class Dialog5e extends Application5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    tag: "dialog",
    window: {
      contentTag: "form",
      contentClasses: ["standard-form"],
      minimizable: false
    }
  };

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

  /** @override */
  static PARTS = {
    content: {
      template: ""
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

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

  /**
   * Form element within the dialog.
   * @type {HTMLFormElement|void}
   */
  get form() {
    return this.options.tag === "form" ? this.element : this.element.querySelector("form");
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = { ...(await super._preparePartContext(partId, context, options)) };
    if ( partId === "content" ) return this._prepareContentContext(context, options);
    if ( partId === "footer" ) return this._prepareFooterContext(context, options);
    return context;
  }

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

  /**
   * Prepare rendering context for the content section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareContentContext(context, options) {
    return context;
  }

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

  /**
   * Prepare rendering context for the footer.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareFooterContext(context, options) {
    context.buttons = this.options.buttons?.map(button => ({
      ...button, cssClass: button.class
    }));
    return context;
  }

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

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();

    // Add event listeners to the form manually (see https://github.com/foundryvtt/foundryvtt/issues/11621)
    if ( this.options.tag !== "form" ) {
      this.form?.addEventListener("submit", this._onSubmitForm.bind(this, this.options.form));
      this.form?.addEventListener("change", this._onChangeForm.bind(this, this.options.form));
    }
  }
}

const { BooleanField: BooleanField$I, NumberField: NumberField$K, StringField: StringField$1b } = foundry.data.fields;

/**
 * Dialog for configuring the usage of an activity.
 */
class ActivityUsageDialog extends Dialog5e {
  constructor(options={}) {
    super(options);
    this.#activityId = options.activity.id;
    this.#item = options.activity.item;
    this.#config = options.config;
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["activity-usage"],
    actions: {
      use: ActivityUsageDialog.#onUse
    },
    activity: null,
    button: {
      icon: null,
      label: null
    },
    config: null,
    display: {
      all: true
    },
    form: {
      handler: ActivityUsageDialog.#onSubmitForm,
      submitOnChange: true
    },
    position: {
      width: 420
    }
  };

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

  /** @override */
  static PARTS = {
    scaling: {
      template: "systems/dnd5e/templates/activity/activity-usage-scaling.hbs"
    },
    concentration: {
      template: "systems/dnd5e/templates/activity/activity-usage-concentration.hbs"
    },
    consumption: {
      template: "systems/dnd5e/templates/activity/activity-usage-consumption.hbs"
    },
    creation: {
      template: "systems/dnd5e/templates/activity/activity-usage-creation.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

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

  /**
   * ID of the activity being activated.
   * @type {Activity}
   */
  #activityId;

  /**
   * Activity being activated.
   * @type {Activity}
   */
  get activity() {
    return this.item.system.activities.get(this.#activityId);
  }

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

  /**
   * Actor using this activity.
   * @type {Actor5e}
   */
  get actor() {
    return this.item.actor;
  }

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

  /**
   * Activity usage configuration data.
   * @type {ActivityUseConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

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

  /**
   * Item that contains the activity.
   * @type {Item5e}
   */
  #item;

  get item() {
    return this.#item;
  }

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

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

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

  /** @override */
  get subtitle() {
    return this.activity.name;
  }

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

  /**
   * Was the use button clicked?
   * @type {boolean}
   */
  #used = false;

  get used() {
    return this.#used;
  }

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

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.isFirstRender ) options.window.icon ||= this.activity.img;
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    if ( "scaling" in this.config ) this.#item = this.#item.clone({ "flags.dnd5e.scaling": this.config.scaling });
    return {
      ...await super._prepareContext(options),
      activity: this.activity,
      linkedActivity: this.config.cause ? this.activity.getLinkedActivity(this.config.cause.activity) : null
    };
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "concentration": return this._prepareConcentrationContext(context, options);
      case "consumption": return this._prepareConsumptionContext(context, options);
      case "creation": return this._prepareCreationContext(context, options);
      case "scaling": return this._prepareScalingContext(context, options);
    }
    return context;
  }

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

  /**
   * Prepare rendering context for the concentration section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareConcentrationContext(context, options) {
    if ( !this.activity.requiresConcentration || game.settings.get("dnd5e", "disableConcentration")
      || !this._shouldDisplay("concentration") ) return context;
    context.hasConcentration = true;
    context.notes = [];

    context.fields = [{
      field: new BooleanField$I({ label: game.i18n.localize("DND5E.Concentration") }),
      name: "concentration.begin",
      value: this.config.concentration?.begin,
      input: context.inputs.createCheckboxInput
    }];
    if ( this.config.concentration?.begin ) {
      const existingConcentration = Array.from(this.actor.concentration.effects).map(effect => {
        const data = effect.getFlag("dnd5e", "item");
        return {
          value: effect.id,
          label: data?.data?.name ?? this.actor.items.get(data?.id)?.name
            ?? game.i18n.localize("DND5E.ConcentratingItemless")
        };
      });
      if ( existingConcentration.length ) {
        const optional = existingConcentration.length < (this.actor.system.attributes?.concentration?.limit ?? 0);
        context.fields.push({
          field: new StringField$1b({ label: game.i18n.localize("DND5E.ConcentratingEnd") }),
          name: "concentration.end",
          value: this.config.concentration?.end,
          options: optional ? [{ value: "", label: "—" }, ...existingConcentration] : existingConcentration
        });
        context.notes.push({
          type: "info", message: game.i18n.localize(`DND5E.ConcentratingWarnLimit${optional ? "Optional" : ""}`)
        });
      } else if ( !this.actor.system.attributes?.concentration?.limit ) {
        context.notes.push({
          type: "warn", message: game.i18n.localize("DND5E.ConcentratingWarnLimitZero")
        });
      }
    }

    return context;
  }

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

  /**
   * Prepare rendering context for the consumption section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareConsumptionContext(context, options) {
    context.fields = [];
    context.notes = [];

    const containsLegendaryConsumption = this.activity.consumption.targets
      .find(t => (t.type === "attribute") && (t.target === "resources.legact.value"));
    if ( (this.activity.activation.type === "legendary") && this.actor.system.resources?.legact
      && this._shouldDisplay("consume.action") && !containsLegendaryConsumption ) {
      const pr = new Intl.PluralRules(game.i18n.lang);
      const value = (this.config.consume !== false) && (this.config.consume?.action !== false);
      const warn = (this.actor.system.resources.legact.value < this.activity.activation.value) && value;
      context.fields.push({
        field: new BooleanField$I({
          label: game.i18n.format("DND5E.CONSUMPTION.Type.Action.Prompt", {
            type: game.i18n.localize("DND5E.LegendaryAction.Label")
          }),
          hint: game.i18n.format("DND5E.CONSUMPTION.Type.Action.PromptHint", {
            available: game.i18n.format(
              `DND5E.ACTIVATION.Type.Legendary.Counted.${pr.select(this.actor.system.resources.legact.value)}`,
              { number: `<strong>${formatNumber(this.actor.system.resources.legact.value)}</strong>` }
            ),
            cost: game.i18n.format(
              `DND5E.ACTIVATION.Type.Legendary.Counted.${pr.select(this.activity.activation.value)}`,
              { number: `<strong>${formatNumber(this.activity.activation.value)}</strong>` }
            )
          })
        }),
        input: context.inputs.createCheckboxInput,
        name: "consume.action",
        value, warn
      });
    }

    if ( this.activity.requiresSpellSlot && this.activity.consumption.spellSlot
      && this._shouldDisplay("consume.spellSlot") && !this.config.cause ) context.fields.push({
      field: new BooleanField$I({ label: game.i18n.localize("DND5E.SpellCastConsume") }),
      input: context.inputs.createCheckboxInput,
      name: "consume.spellSlot",
      value: this.config.consume?.spellSlot
    });

    if ( this._shouldDisplay("consume.resources") ) {
      const addResources = (targets, keyPath) => {
        const consume = foundry.utils.getProperty(this.config, keyPath);
        const isArray = foundry.utils.getType(consume) === "Array";
        for ( const [index, target] of targets.entries() ) {
          const value = (isArray && consume.includes(index))
            || (!isArray && (consume !== false) && (this.config.consume !== false));
          const { label, hint, notes, warn } = target.getConsumptionLabels(this.config, value);
          if ( notes?.length ) context.notes.push(...notes);
          context.fields.push({
            field: new BooleanField$I({ label, hint }),
            input: context.inputs.createCheckboxInput,
            name: `${keyPath}.${index}`,
            value,
            warn: value ? warn : false
          });
        }
      };
      addResources(this.activity.consumption.targets, "consume.resources");
      if ( context.linkedActivity && (!this.activity.isSpell || this.activity.consumption.spellSlot) ) {
        addResources(context.linkedActivity.consumption.targets, "cause.resources");
      }
    }

    context.hasConsumption = context.fields.length > 0;

    return context;
  }

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

  /**
   * Prepare rendering context for the creation section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareCreationContext(context, options) {
    context.hasCreation = false;
    if ( this.activity.target?.template?.type && this._shouldDisplay("create.measuredTemplate") ) {
      context.hasCreation = true;
      context.template = {
        field: new BooleanField$I({ label: game.i18n.localize("DND5E.TARGET.Action.PlaceTemplate") }),
        name: "create.measuredTemplate",
        value: this.config.create?.measuredTemplate
      };
    }
    return context;
  }

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

  /**
   * Prepare rendering context for the footer.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareFooterContext(context, options) {
    context.buttons = [{
      action: "use",
      icon: this.options.button.icon ?? `fa-solid fa-${this.activity.isSpell ? "magic" : "fist-raised"}`,
      label: this.options.button.label ?? `DND5E.AbilityUse${this.activity.isSpell ? "Cast" : "Use"}`,
      type: "button"
    }];
    return context;
  }

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

  /**
   * Prepare rendering context for the scaling section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareScalingContext(context, options) {
    context.hasScaling = true;
    context.notes = [];
    if ( !this._shouldDisplay("scaling") ) {
      context.hasScaling = false;
      return context;
    }

    const scale = (context.linkedActivity ?? this.activity).consumption.scaling;
    const rollData = (context.linkedActivity ?? this.activity).getRollData({ deterministic: true });

    if ( this.activity.requiresSpellSlot && context.linkedActivity && (this.config.scaling !== false) ) {
      const max = simplifyBonus(scale.max, rollData);
      const minimumLevel = context.linkedActivity.spell?.level ?? this.item.system.level ?? 1;
      const maximumLevel = scale.allowed ? scale.max ? minimumLevel + max - 1 : Infinity : minimumLevel;
      const spellSlotOptions = Object.entries(CONFIG.DND5E.spellLevels).map(([level, label]) => {
        if ( (Number(level) < minimumLevel) || (Number(level) > maximumLevel) ) return null;
        return { value: `spell${level}`, label };
      }).filter(_ => _);
      context.spellSlots = {
        field: new StringField$1b({ label: game.i18n.localize("DND5E.SpellCastUpcast") }),
        name: "spell.slot",
        value: this.config.spell?.slot,
        options: spellSlotOptions
      };
    }

    else if ( this.activity.requiresSpellSlot && (this.config.scaling !== false) ) {
      const minimumLevel = this.item.system.level ?? 1;
      const maximumLevel = Object.values(this.actor.system.spells)
        .reduce((max, d) => d.max ? Math.max(max, d.level) : max, 0);

      const consumeSlot = (this.config.consume === true) || this.config.consume?.spellSlot;
      let spellSlotValue = this.actor.system.spells[this.config.spell?.slot]?.value || !consumeSlot
        ? this.config.spell.slot : null;
      const spellSlotOptions = Object.entries(this.actor.system.spells).map(([value, slot]) => {
        if ( (slot.level < minimumLevel) || (slot.level > maximumLevel) || !slot.type ) return null;
        let label;
        if ( slot.type === "leveled" ) {
          label = game.i18n.format("DND5E.SpellLevelSlot", { level: slot.label, n: slot.value });
        } else {
          label = game.i18n.format(`DND5E.SpellLevel${slot.type.capitalize()}`, { level: slot.level, n: slot.value });
        }
        // Set current value if applicable.
        const disabled = (slot.value === 0) && consumeSlot;
        if ( !disabled && !spellSlotValue ) spellSlotValue = value;
        return { value, label, disabled, selected: spellSlotValue === value };
      }).filter(_ => _);

      context.spellSlots = {
        field: new StringField$1b({ label: game.i18n.localize("DND5E.SpellCastUpcast") }),
        name: "spell.slot",
        value: spellSlotValue,
        options: spellSlotOptions
      };

      if ( !spellSlotOptions.some(o => !o.disabled) ) context.notes.push({
        type: "warn", message: game.i18n.format("DND5E.SpellCastNoSlotsLeft", {
          name: this.item.name
        })
      });
    }

    else if ( scale.allowed && (this.config.scaling !== false) ) {
      const max = scale.max ? simplifyBonus(scale.max, rollData) : Infinity;
      if ( max > 1 ) context.scaling = {
        field: new NumberField$K({ min: 1, max, label: game.i18n.localize("DND5E.ScalingValue") }),
        name: "scalingValue",
        // Config stores the scaling increase, but scaling value (increase + 1) is easier to understand in the UI
        value: Math.clamp((this.config.scaling ?? 0) + 1, 1, max),
        max,
        showRange: max <= 20
      };
      else context.hasScaling = false;
    }

    else {
      context.hasScaling = false;
    }

    return context;
  }

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

  /**
   * Determine whether a particular element should be displayed based on the `display` options.
   * @param {string} section  Key path describing the section to be displayed.
   * @returns {boolean}
   */
  _shouldDisplay(section) {
    const display = this.options.display;
    if ( foundry.utils.hasProperty(display, section) ) return foundry.utils.getProperty(display, section);
    const [group] = section.split(".");
    if ( (group !== section) && (group in display) ) return display[group];
    return this.options.display.all ?? true;
  }

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

  /**
   * Handle form submission.
   * @this {ActivityUsageDialog}
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {HTMLFormElement} form       The form that was submitted.
   * @param {FormDataExtended} formData  Data from the submitted form.
   */
  static async #onSubmitForm(event, form, formData) {
    const submitData = await this._prepareSubmitData(event, formData);
    await this._processSubmitData(event, submitData);
  }

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

  /**
   * Handle clicking the use button.
   * @this {ActivityUsageDialog}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #onUse(event, target) {
    const formData = new FormDataExtended(this.element.querySelector("form"));
    const submitData = await this._prepareSubmitData(event, formData);
    foundry.utils.mergeObject(this.#config, submitData);
    this.#used = true;
    this.close();
  }

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

  /**
   * Perform any pre-processing of the form data to prepare it for updating.
   * @param {SubmitEvent} event          Triggering submit event.
   * @param {FormDataExtended} formData  Data from the submitted form.
   * @returns {Promise<object>}
   */
  async _prepareSubmitData(event, formData) {
    const submitData = foundry.utils.expandObject(formData.object);
    if ( foundry.utils.hasProperty(submitData, "spell.slot") ) {
      const level = this.actor.system.spells?.[submitData.spell.slot]?.level ?? 0;
      submitData.scaling = Math.max(0, level - this.item.system.level);
    } else if ( "scalingValue" in submitData ) {
      submitData.scaling = submitData.scalingValue - 1;
      delete submitData.scalingValue;
    }
    for ( const key of ["consume", "cause"] ) {
      if ( foundry.utils.getType(submitData[key]?.resources) === "Object" ) {
        submitData[key].resources = filteredKeys(submitData[key].resources).map(i => Number(i));
      }
    }
    return submitData;
  }

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

  /**
   * Handle updating the usage configuration based on processed submit data.
   * @param {SubmitEvent} event  Triggering submit event.
   * @param {object} submitData  Prepared object for updating.
   */
  async _processSubmitData(event, submitData) {
    foundry.utils.mergeObject(this.#config, submitData);
    this.render();
  }

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

  /**
   * Display the activity usage dialog.
   * @param {Activity} activity                Activity to use.
   * @param {ActivityUseConfiguration} config  Configuration data for the usage.
   * @param {object} options                   Additional options for the application.
   * @returns {Promise<object|null>}           Form data object with results of the activation.
   */
  static async create(activity, config, options) {
    if ( !activity.item.isOwned ) throw new Error("Cannot activate an activity that is not owned.");

    return new Promise((resolve, reject) => {
      const dialog = new this({ activity, config, ...options });
      dialog.addEventListener("close", event => {
        if ( dialog.used ) resolve(dialog.config);
        else reject();
      }, { once: true });
      dialog.render({ force: true });
    });
  }
}

/**
 * A helper class for building MeasuredTemplates for 5e spells and abilities
 */
class AbilityTemplate extends (foundry.canvas?.placeables?.MeasuredTemplate ?? MeasuredTemplate) {

  /**
   * Track the timestamp when the last mouse move event was captured.
   * @type {number}
   */
  #moveTime = 0;

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

  /**
   * Current token that is highlighted when using adjusted size template.
   * @type {Token5e}
   */
  #hoveredToken;

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

  /**
   * The initially active CanvasLayer to re-activate after the workflow is complete.
   * @type {CanvasLayer}
   */
  #initialLayer;

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

  /**
   * Track the bound event handlers so they can be properly canceled later.
   * @type {object}
   */
  #events;

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

  /**
   * A factory method to create an AbilityTemplate instance using provided data from an Activity instance.
   * @param {Activity} activity         The Activity for which to construct the template.
   * @param {object} [options={}]       Options to modify the created template.
   * @returns {AbilityTemplate[]|null}  The template objects, or null if the item does not produce a template.
   */
  static fromActivity(activity, options={}) {
    const target = activity.target?.template ?? {};
    const templateShape = dnd5e.config.areaTargetTypes[target.type]?.template;
    if ( !templateShape ) return null;

    // Prepare template data
    const rollData = activity.getRollData();
    const templateData = foundry.utils.mergeObject({
      t: templateShape,
      user: game.user.id,
      distance: target.size,
      direction: 0,
      x: 0,
      y: 0,
      fillColor: game.user.color,
      flags: { dnd5e: {
        dimensions: {
          size: target.size,
          width: target.width,
          height: target.height,
          adjustedSize: target.type === "radius"
        },
        item: activity.item.uuid,
        origin: activity.uuid,
        spellLevel: rollData.item.level
      } }
    }, options);

    // Additional type-specific data
    switch ( templateShape ) {
      case "cone":
        templateData.angle = CONFIG.MeasuredTemplate.defaults.angle;
        break;
      case "rect": // 5e rectangular AoEs are always cubes
        templateData.width = target.size;
        if ( game.settings.get("dnd5e", "gridAlignedSquareTemplates") ) {
          templateData.distance = Math.hypot(target.size, target.size);
          templateData.direction = 45;
        } else {
          // Override as 'ray' to make the template able to be rotated without morphing its shape
          templateData.t = "ray";
        }
        break;
      case "ray": // 5e rays are most commonly 1 square (5 ft) in width
        templateData.width = target.width ?? canvas.dimensions.distance;
        break;
    }

    /**
     * A hook event that fires before a template is created for an Activity.
     * @function dnd5e.preCreateActivityTemplate
     * @memberof hookEvents
     * @param {Activity} activity    Activity for which the template is being placed.
     * @param {object} templateData  Data used to create the new template.
     * @returns {boolean}            Explicitly return `false` to prevent the template from being placed.
     */
    if ( Hooks.call("dnd5e.preCreateActivityTemplate", activity, templateData) === false ) return null;

    if ( "dnd5e.preCreateItemTemplate" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preCreateItemTemplate` hook has been deprecated and replaced with `dnd5e.preCreateActivityTemplate`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      if ( Hooks.call("dnd5e.preCreateItemTemplate", activity.item, templateData) === false ) return null;
    }

    // Construct the templates from activity data
    const cls = CONFIG.MeasuredTemplate.documentClass;
    const created = Array.fromRange(target.count || 1).map(() => {
      const template = new cls(foundry.utils.deepClone(templateData), { parent: canvas.scene });
      const object = new this(template);
      object.activity = activity;
      object.item = activity.item;
      object.actorSheet = activity.actor?.sheet || null;
      return object;
    });

    /**
     * A hook event that fires after a template are created for an Activity.
     * @function dnd5e.createActivityTemplate
     * @memberof hookEvents
     * @param {Activity} activity            Activity for which the template is being placed.
     * @param {AbilityTemplate[]} templates  The templates being placed.
     */
    Hooks.callAll("dnd5e.createActivityTemplate", activity, created);

    if ( "dnd5e.createItemTemplate" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.createItemTemplate` hook has been deprecated and replaced with `dnd5e.createActivityTemplate`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      Hooks.callAll("dnd5e.createItemTemplate", activity.item, created[0]);
    }

    return created;
  }

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

  /**
   * A factory method to create an AbilityTemplate instance using provided data from an Item5e instance
   * @param {Item5e} item               The Item object for which to construct the template
   * @param {object} [options={}]       Options to modify the created template.
   * @returns {AbilityTemplate|null}    The template object, or null if the item does not produce a template
   * @deprecated since DnD5e 4.0, available until DnD5e 4.4
   */
  static fromItem(item, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `AbilityTemplate#fromItem` method has been deprecated and replaced with `fromActivity`.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );
    const activity = this.system.activities?.contents[0];
    if ( activity ) return this.fromActivity(activity, options)?.[0] ?? null;
    return null;
  }

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

  /**
   * Creates a preview of the spell template.
   * @returns {Promise}  A promise that resolves with the final measured template if created.
   */
  drawPreview() {
    const initialLayer = canvas.activeLayer;

    // Draw the template and switch to the template layer
    this.draw();
    this.layer.activate();
    this.layer.preview.addChild(this);

    // Hide the sheet that originated the preview
    this.actorSheet?.minimize();

    // Activate interactivity
    return this.activatePreviewListeners(initialLayer);
  }

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

  /**
   * Activate listeners for the template preview
   * @param {CanvasLayer} initialLayer  The initially active CanvasLayer to re-activate after the workflow is complete
   * @returns {Promise}                 A promise that resolves with the final measured template if created.
   */
  activatePreviewListeners(initialLayer) {
    return new Promise((resolve, reject) => {
      this.#initialLayer = initialLayer;
      this.#events = {
        cancel: this._onCancelPlacement.bind(this),
        confirm: this._onConfirmPlacement.bind(this),
        move: this._onMovePlacement.bind(this),
        resolve,
        reject,
        rotate: this._onRotatePlacement.bind(this)
      };

      // Activate listeners
      canvas.stage.on("mousemove", this.#events.move);
      canvas.stage.on("mousedown", this.#events.confirm);
      canvas.app.view.oncontextmenu = this.#events.cancel;
      canvas.app.view.onwheel = this.#events.rotate;
    });
  }

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

  /**
   * Shared code for when template placement ends by being confirmed or canceled.
   * @param {Event} event  Triggering event that ended the placement.
   */
  async _finishPlacement(event) {
    this.layer._onDragLeftCancel(event);
    canvas.stage.off("mousemove", this.#events.move);
    canvas.stage.off("mousedown", this.#events.confirm);
    canvas.app.view.oncontextmenu = null;
    canvas.app.view.onwheel = null;
    if ( this.#hoveredToken ) {
      this.#hoveredToken._onHoverOut(event);
      this.#hoveredToken = null;
    }
    this.#initialLayer.activate();
    await this.actorSheet?.maximize();
  }

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

  /**
   * Move the template preview when the mouse moves.
   * @param {Event} event  Triggering mouse event.
   */
  _onMovePlacement(event) {
    event.stopPropagation();
    const now = Date.now(); // Apply a 20ms throttle
    if ( now - this.#moveTime <= 20 ) return;
    const center = event.data.getLocalPosition(this.layer);
    const updates = this.getSnappedPosition(center);

    // Adjust template size to take hovered token into account if `adjustedSize` is set
    const baseDistance = this.document.flags.dnd5e?.dimensions?.size;
    if ( this.document.flags.dnd5e?.dimensions?.adjustedSize && baseDistance ) {
      const rectangle = new PIXI.Rectangle(center.x, center.y, 1, 1);
      const hoveredToken = canvas.tokens.quadtree.getObjects(rectangle, {
        collisionTest: ({ t }) => t.visible && !t.document.isSecret }).first();
      if ( hoveredToken && (hoveredToken !== this.#hoveredToken) ) {
        this.#hoveredToken = hoveredToken;
        this.#hoveredToken._onHoverIn(event);
        const size = Math.max(hoveredToken.document.width, hoveredToken.document.height);
        updates.distance = baseDistance + (size * canvas.grid.distance / 2);
      } else if ( !hoveredToken && this.#hoveredToken ) {
        this.#hoveredToken._onHoverOut(event);
        this.#hoveredToken = null;
        updates.distance = baseDistance;
      }
    }

    this.document.updateSource(updates);
    this.refresh();
    this.#moveTime = now;
  }

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

  /**
   * Rotate the template preview by 3˚ increments when the mouse wheel is rotated.
   * @param {Event} event  Triggering mouse event.
   */
  _onRotatePlacement(event) {
    if ( event.ctrlKey ) event.preventDefault(); // Avoid zooming the browser window
    event.stopPropagation();
    const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15;
    const snap = event.shiftKey ? delta : 5;
    const update = {direction: this.document.direction + (snap * Math.sign(event.deltaY))};
    this.document.updateSource(update);
    this.refresh();
  }

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

  /**
   * Confirm placement when the left mouse button is clicked.
   * @param {Event} event  Triggering mouse event.
   */
  async _onConfirmPlacement(event) {
    await this._finishPlacement(event);
    const destination = canvas.templates.getSnappedPoint({ x: this.document.x, y: this.document.y });
    this.document.updateSource(destination);
    this.#events.resolve(canvas.scene.createEmbeddedDocuments("MeasuredTemplate", [this.document.toObject()]));
  }

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

  /**
   * Cancel placement when the right mouse button is clicked.
   * @param {Event} event  Triggering mouse event.
   */
  async _onCancelPlacement(event) {
    await this._finishPlacement(event);
    this.#events.reject();
  }

}

/**
 * A mixin which extends a DataModel to provide behavior shared between activities & advancements.
 * @template {DataModel} T
 * @param {typeof T} Base  The base DataModel to be mixed.
 * @returns {typeof PseudoDocument}
 * @mixin
 */
function PseudoDocumentMixin(Base) {
  class PseudoDocument extends Base {
    constructor(data, { parent=null, ...options }={}) {
      if ( parent instanceof Item ) parent = parent.system;
      super(data, { parent, ...options });
    }

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

    /**
     * Mapping of PseudoDocument UUID to the apps they should re-render.
     * @type {Map<string, Set<Application|ApplicationV2>>}
     * @internal
     */
    static _apps = new Map();

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

    /**
     * Existing sheets of a specific type for a specific document.
     * @type {Map<[PseudoDocument, typeof ApplicationV2], ApplicationV2>}
     */
    static _sheets = new Map();

    /* -------------------------------------------- */
    /*  Model Configuration                         */
    /* -------------------------------------------- */

    /**
     * Configuration information for PseudoDocuments.
     *
     * @typedef PseudoDocumentsMetadata
     * @property {string} name        Base type name of this PseudoDocument (e.g. "Activity", "Advancement").
     * @property {string} label       Localized name for this PseudoDocument type.
     */

    /**
     * Configuration information for PseudoDocuments.
     * @type {PseudoDocumentsMetadata}
     */
    get metadata() {
      return this.constructor.metadata;
    }

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

    /**
     * Configuration object that defines types.
     * @type {object}
     */
    static get documentConfig() {
      return CONFIG.DND5E[`${this.documentName.toLowerCase()}Types`];
    }

    get documentConfig() {
      return this.constructor.documentConfig;
    }

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

    /**
     * The canonical name of this PseudoDocument type, for example "Activity".
     * @type {string}
     */
    static get documentName() {
      return this.metadata.name;
    }

    get documentName() {
      return this.constructor.documentName;
    }

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

    /**
     * Unique identifier for this PseudoDocument within its item.
     * @type {string}
     */
    get id() {
      return this._id;
    }

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

    /**
     * Unique ID for this PseudoDocument on an actor.
     * @type {string}
     */
    get relativeID() {
      return `${this.item.id}.${this.id}`;
    }

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

    /**
     * Globally unique identifier for this PseudoDocument.
     * @type {string}
     */
    get uuid() {
      return `${this.item.uuid}.${this.documentName}.${this.id}`;
    }

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

    /**
     * Item to which this PseudoDocument belongs.
     * @type {Item5e}
     */
    get item() {
      return this.parent.parent;
    }

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

    /**
     * Actor to which this PseudoDocument's item belongs, if the item is embedded.
     * @type {Actor5e|null}
     */
    get actor() {
      return this.item.parent ?? null;
    }

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

    /**
     * Lazily obtain a Application instance used to configure this PseudoDocument, or null if no sheet is available.
     * @type {Application|ApplicationV2|null}
     */
    get sheet() {
      const cls = this.constructor.metadata.sheetClass ?? this.constructor.metadata.apps?.config;
      if ( !cls ) return null;
      if ( !this.constructor._sheets.has(this.uuid) ) {
        let sheet;
        if ( Application.isPrototypeOf(cls) ) sheet = new cls(this);
        else sheet = new cls({ document: this });
        this.constructor._sheets.set(this.uuid, sheet);
      }
      return this.constructor._sheets.get(this.uuid);
    }

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

    /**
     * Render all the Application instances which are connected to this PseudoDocument.
     * @param {ApplicationRenderOptions} [options]  Rendering options.
     */
    render(options) {
      for ( const app of this.constructor._apps.get(this.uuid) ?? [] ) {
        app.render({ window: { title: app.title }, ...options });
      }
    }

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

    /**
     * Register an application to respond to updates to a certain document.
     * @param {PseudoDocument} doc  Pseudo document to watch.
     * @param {Application} app     Application to update.
     * @internal
     */
    static _registerApp(doc, app) {
      if ( !this._apps.has(doc.uuid) ) this._apps.set(doc.uuid, new Set());
      this._apps.get(doc.uuid).add(app);
    }

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

    /**
     * Remove an application from the render registry.
     * @param {PseudoDocument} doc  Pseudo document being watched.
     * @param {Application} app     Application to stop watching.
     */
    static _unregisterApp(doc, app) {
      this._apps.get(doc?.uuid)?.delete(app);
    }

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

    /**
     * Update this PseudoDocument.
     * @param {object} updates             Updates to apply to this PseudoDocument.
     * @param {object} [options={}]        Additional context which customizes the update workflow.
     * @returns {Promise<PseudoDocument>}  This PseudoDocument after updates have been applied.
     */
    async update(updates, options={}) {
      const result = await this.item[`update${this.documentName}`](this.id, updates, options);
      this.render();
      return result;
    }

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

    /**
     * Update this PseudoDocument's data on the item without performing a database commit.
     * @param {object} updates    Updates to apply to this PseudoDocument.
     * @returns {PseudoDocument}  This PseudoDocument after updates have been applied.
     */
    updateSource(updates) {
      super.updateSource(updates);
      return this;
    }

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

    /**
     * Delete this PseudoDocument, removing it from the database.
     * @param {object} [options={}]        Additional context which customizes the deletion workflow.
     * @returns {Promise<PseudoDocument>}  The deleted PseudoDocument instance.
     */
    async delete(options={}) {
      return await this.item[`delete${this.documentName}`](this.id, options);
    }

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

    /**
     * Present a Dialog form to confirm deletion of this PseudoDocument.
     * @param {object} [options]           Positioning and sizing options for the resulting dialog.
     * @returns {Promise<PseudoDocument>}  A Promise which resolves to the deleted PseudoDocument.
     */
    async deleteDialog(options={}) {
      const type = game.i18n.localize(this.metadata.label);
      return Dialog.confirm({
        title: `${game.i18n.format("DOCUMENT.Delete", { type })}: ${this.name || this.title}`,
        content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.format("SIDEBAR.DeleteWarning", {
          type
        })}</p>`,
        yes: this.delete.bind(this),
        options: options
      });
    }

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

    /**
     * Serialize salient information for this PseudoDocument when dragging it.
     * @returns {object}  An object of drag data.
     */
    toDragData() {
      const dragData = { type: this.documentName, data: this.toObject() };
      if ( this.id ) dragData.uuid = this.uuid;
      return dragData;
    }

    /* -------------------------------------------- */
    /*  Importing and Exporting                     */
    /* -------------------------------------------- */

    /**
     * Spawn a dialog for creating a new Activity.
     * @param {object} [data]  Data to pre-populate the Activity with.
     * @param {object} context
     * @param {Item5e} context.parent        A parent for the Activity.
     * @param {string[]|null} [context.types]  A list of types to restrict the choices to, or null for no restriction.
     * @returns {Promise<Item5e|null>}
     */
    static async createDialog(data={}, { parent, types=null, ...options }={}) {
      types ??= Object.keys(this.documentConfig);
      if ( !types.length || !parent ) return null;

      const label = game.i18n.localize(`DOCUMENT.DND5E.${this.documentName}`);
      const title = game.i18n.format("DOCUMENT.Create", { type: label });
      let type = data.type;

      if ( !types.includes(type) ) type = types[0];
      const content = await renderTemplate("systems/dnd5e/templates/apps/document-create.hbs", {
        name, type,
        types: types.reduce((arr, type) => {
          const label = this.documentConfig[type]?.documentClass?.metadata?.title;
          arr.push({
            type,
            label: game.i18n.has(label) ? game.i18n.localize(label) : type,
            icon: this.documentConfig[type]?.documentClass?.metadata?.img
          });
          return arr;
        }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
      });
      return Dialog.prompt({
        title, content,
        label: title,
        render: html => {
          const app = html.closest(".app");
          const folder = app.querySelector("select");
          if ( folder ) app.querySelector(".dialog-buttons").insertAdjacentElement("afterbegin", folder);
          app.querySelectorAll(".window-header .header-button").forEach(btn => {
            const label = btn.innerText;
            const icon = btn.querySelector("i");
            btn.innerHTML = icon.outerHTML;
            btn.dataset.tooltip = label;
            btn.setAttribute("aria-label", label);
          });
          app.querySelector(".document-name").select();
        },
        callback: html => {
          const form = html.querySelector("form");
          if ( !form.checkValidity() ) {
            throw new Error(game.i18n.format("DOCUMENT.DND5E.Warning.SelectType", { name: label }));
          }
          const fd = new FormDataExtended(form);
          const createData = foundry.utils.mergeObject(data, fd.object, { inplace: false });
          if ( !createData.name?.trim() ) delete createData.name;
          parent[`create${this.documentName}`](createData.type, createData);
        },
        rejectClose: false,
        options: { ...options, jQuery: false, width: 350, classes: ["dnd5e2", "create-document", "dialog"] }
      });
    }
  }
  return PseudoDocument;
}

/**
 * @import { PseudoDocumentsMetadata } from "../mixins/pseudo-document.mjs";
 */

/**
 * Mixin used to provide base logic to all activities.
 * @template {BaseActivityData} T
 * @param {typeof T} Base  The base activity data class to wrap.
 * @returns {typeof Activity}
 * @mixin
 */
function ActivityMixin(Base) {
  class Activity extends PseudoDocumentMixin(Base) {
    /**
     * Configuration information for Activities.
     *
     * @typedef {PseudoDocumentsMetadata} ActivityMetadata
     * @property {string} type                              Type name of this activity.
     * @property {string} img                               Default icon.
     * @property {string} title                             Default title.
     * @property {typeof ActivitySheet} sheetClass          Sheet class used to configure this activity.
     * @property {object} usage
     * @property {Record<string, Function>} usage.actions   Actions that can be triggered from the chat card.
     * @property {string} usage.chatCard                    Template used to render the chat card.
     * @property {typeof ActivityUsageDialog} usage.dialog  Default usage prompt.
     */

    /**
     * Configuration information for this PseudoDocument.
     * @type {Readonly<ActivityMetadata>}
     */
    static metadata = Object.freeze({
      name: "Activity",
      label: "DOCUMENT.DND5E.Activity",
      sheetClass: ActivitySheet,
      usage: {
        actions: {},
        chatCard: "systems/dnd5e/templates/chat/activity-card.hbs",
        dialog: ActivityUsageDialog
      }
    });

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

    /**
     * Perform the pre-localization of this data model.
     */
    static localize() {
      Localization.localizeDataModel(this);
      const fields = this.schema.fields;
      if ( fields.damage?.fields.parts ) {
        localizeSchema(fields.damage.fields.parts.element, ["DND5E.DAMAGE.FIELDS.damage.parts"]);
      }
      if ( fields.consumption ) {
        localizeSchema(fields.consumption.fields.targets.element, ["DND5E.CONSUMPTION.FIELDS.consumption.targets"]);
      }
      if ( fields.uses ) localizeSchema(fields.uses.fields.recovery.element, ["DND5E.USES.FIELDS.uses.recovery"]);
    }

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

    /**
     * Perform pre-localization on the contents of a SchemaField. Necessary because the `localizeSchema` method
     * on `Localization` is private.
     * @param {SchemaField} schema
     * @param {string[]} prefixes
     * @internal
     */
    static _localizeSchema(schema, prefixes) {
      localizeSchema(schema, prefixes);
    }

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

    /**
     * Should this activity be able to be used?
     * @type {boolean}
     */
    get canUse() {
      return true;
    }

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

    /**
     * Description used in chat message flavor for messages created with `rollDamage`.
     * @type {string}
     */
    get damageFlavor() {
      return game.i18n.localize("DND5E.DamageRoll");
    }

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

    /**
     * Create the data added to messages flags.
     * @type {object}
     */
    get messageFlags() {
      return {
        activity: { type: this.type, id: this.id, uuid: this.uuid },
        item: { type: this.item.type, id: this.item.id, uuid: this.item.uuid },
        targets: getTargetDescriptors()
      };
    }

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

    /**
     * Relative UUID for this activity on an actor.
     * @type {string}
     */
    get relativeUUID() {
      return `.Item.${this.item.id}.Activity.${this.id}`;
    }

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

    /**
     * Consumption targets that can be use for this activity.
     * @type {Set<string>}
     */
    get validConsumptionTypes() {
      const types = new Set(Object.keys(CONFIG.DND5E.activityConsumptionTypes));
      if ( this.isSpell ) types.delete("spellSlots");
      return types;
    }

    /* -------------------------------------------- */
    /*  Activation                                  */
    /* -------------------------------------------- */

    /**
     * Configuration data for an activity usage being prepared.
     *
     * @typedef {object} ActivityUseConfiguration
     * @property {object|false} create
     * @property {boolean} create.measuredTemplate     Should this item create a template?
     * @property {object} concentration
     * @property {boolean} concentration.begin         Should this usage initiate concentration?
     * @property {string|null} concentration.end       ID of an active effect to end concentration on.
     * @property {object|false} consume
     * @property {boolean} consume.action              Should action economy be tracked? Currently only handles
     *                                                 legendary actions.
     * @property {boolean|number[]} consume.resources  Set to `true` or `false` to enable or disable all resource
     *                                                 consumption or provide a list of consumption target indexes
     *                                                 to only enable those targets.
     * @property {boolean} consume.spellSlot           Should this spell consume a spell slot?
     * @property {Event} event                         The browser event which triggered the item usage, if any.
     * @property {boolean|number} scaling              Number of steps above baseline to scale this usage, or `false` if
     *                                                 scaling is not allowed.
     * @property {object} spell
     * @property {number} spell.slot                   The spell slot to consume.
     * @property {boolean} [subsequentActions=true]    Trigger subsequent actions defined by this activity.
     * @property {object} [cause]
     * @property {string} [cause.activity]             Relative UUID to the activity that caused this one to be used.
     *                                                 Activity must be on the same actor as this one.
     * @property {boolean|number[]} [cause.resources]  Control resource consumption on linked item.
     */

    /**
     * Data for the activity activation configuration dialog.
     *
     * @typedef {object} ActivityDialogConfiguration
     * @property {boolean} [configure=true]  Display a configuration dialog for the item usage, if applicable?
     * @property {typeof ActivityUsageDialog} [applicationClass]  Alternate activation dialog to use.
     * @property {object} [options]          Options passed through to the dialog.
     */

    /**
     * Message configuration for activity usage.
     *
     * @typedef {object} ActivityMessageConfiguration
     * @property {boolean} [create=true]     Whether to automatically create a chat message (if true) or simply return
     *                                       the prepared chat message data (if false).
     * @property {object} [data={}]          Additional data used when creating the message.
     * @property {boolean} [hasConsumption]  Was consumption available during activation.
     * @property {string} [rollMode]         The roll display mode with which to display (or not) the card.
     */

    /**
     * Details of final changes performed by the usage.
     *
     * @typedef {object} ActivityUsageResults
     * @property {ActiveEffect5e[]} effects              Active effects that were created or deleted.
     * @property {ChatMessage5e|object} message          The chat message created for the activation, or the message
     *                                                   data if `create` in ActivityMessageConfiguration was `false`.
     * @property {MeasuredTemplateDocument[]} templates  Created measured templates.
     * @property {ActivityUsageUpdates} updates          Updates to the actor & items.
     */

    /**
     * Activate this activity.
     * @param {ActivityUseConfiguration} usage        Configuration info for the activation.
     * @param {ActivityDialogConfiguration} dialog    Configuration info for the usage dialog.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created chat message.
     * @returns {Promise<ActivityUsageResults|void>}  Details on the usage process if not canceled.
     */
    async use(usage={}, dialog={}, message={}) {
      if ( !this.item.isEmbedded || this.item.pack ) return;
      if ( !this.item.isOwner ) {
        ui.notifications.error("DND5E.DocumentUseWarn", { localize: true });
        return;
      }
      if ( !this.canUse ) {
        ui.notifications.error("DND5E.ACTIVITY.Warning.UsageNotAllowed", { localize: true });
        return;
      }

      // Create an item clone to work with throughout the rest of the process
      let item = this.item.clone({}, { keepId: true });
      let activity = item.system.activities.get(this.id);

      const usageConfig = activity._prepareUsageConfig(usage);

      const dialogConfig = foundry.utils.mergeObject({
        configure: true,
        applicationClass: this.metadata.usage.dialog
      }, dialog);

      const messageConfig = foundry.utils.mergeObject({
        create: true,
        data: {
          flags: {
            dnd5e: {
              ...this.messageFlags,
              messageType: "usage",
              use: {
                effects: this.applicableEffects?.map(e => e.id)
              }
            }
          }
        },
        hasConsumption: usageConfig.hasConsumption
      }, message);

      /**
       * A hook event that fires before an activity usage is configured.
       * @function dnd5e.preUseActivity
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being used.
       * @param {ActivityUseConfiguration} usageConfig        Configuration info for the activation.
       * @param {ActivityDialogConfiguration} dialogConfig    Configuration info for the usage dialog.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being used.
       */
      if ( Hooks.call("dnd5e.preUseActivity", activity, usageConfig, dialogConfig, messageConfig) === false ) return;

      if ( "dnd5e.preUseItem" in Hooks.events ) {
        foundry.utils.logCompatibilityWarning(
          "The `dnd5e.preUseItem` hook has been deprecated and replaced with `dnd5e.preUseActivity`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        const { config, options } = this._createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig);
        if ( Hooks.call("dnd5e.preUseItem", item, config, options) === false ) return;
        this._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options);
      }

      // Display configuration window if necessary
      if ( dialogConfig.configure && activity._requiresConfigurationDialog(usageConfig) ) {
        try {
          await dialogConfig.applicationClass.create(activity, usageConfig, dialogConfig.options);
        } catch(err) {
          return;
        }
      }

      // Handle scaling
      await activity._prepareUsageScaling(usageConfig, messageConfig, item);
      activity = item.system.activities.get(this.id);

      // Handle consumption
      const updates = await activity.consume(usageConfig, messageConfig);
      if ( updates === false ) return;
      const results = { effects: [], templates: [], updates };

      // Create concentration effect & end previous effects
      if ( usageConfig.concentration?.begin ) {
        const effect = await item.actor.beginConcentrating(activity, { "flags.dnd5e.scaling": usageConfig.scaling });
        if ( effect ) {
          results.effects ??= [];
          results.effects.push(effect);
          foundry.utils.setProperty(messageConfig.data, "flags.dnd5e.use.concentrationId", effect.id);
        }
        if ( usageConfig.concentration?.end ) {
          const deleted = await item.actor.endConcentration(usageConfig.concentration.end);
          results.effects.push(...deleted);
        }
      }

      // Create chat message
      messageConfig.data.rolls = (messageConfig.data.rolls ?? []).concat(updates.rolls);
      results.message = await activity._createUsageMessage(messageConfig);

      // Perform any final usage steps
      await activity._finalizeUsage(usageConfig, results);

      /**
       * A hook event that fires when an activity is activated.
       * @function dnd5e.postUseActivity
       * @memberof hookEvents
       * @param {Activity} activity                     Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig  Configuration data for the activation.
       * @param {ActivityUsageResults} results          Final details on the activation.
       * @returns {boolean}  Explicitly return `false` to prevent any subsequent actions from being triggered.
       */
      if ( Hooks.call("dnd5e.postUseActivity", activity, usageConfig, results) === false ) return results;

      if ( "dnd5e.useItem" in Hooks.events ) {
        foundry.utils.logCompatibilityWarning(
          "The `dnd5e.useItem` hook has been deprecated and replaced with `dnd5e.postUseActivity`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        const { config, options } = this._createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig);
        Hooks.callAll("dnd5e.itemUsageConsumption", item, config, options, results.templates, results.effects, null);
      }

      // Trigger any primary action provided by this activity
      if ( usageConfig.subsequentActions !== false ) {
        activity._triggerSubsequentActions(usageConfig, results);
      }

      return results;
    }

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

    /**
     * Consume this activation's usage.
     * @param {ActivityUseConfiguration} usageConfig        Usage configuration.
     * @param {ActivityMessageConfiguration} messageConfig  Configuration data for the chat message.
     * @returns {ActivityUsageUpdates|false}
     */
    async consume(usageConfig, messageConfig) {
      /**
       * A hook event that fires before an item's resource consumption is calculated.
       * @function dnd5e.preActivityConsumption
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being activated.
       */
      if ( Hooks.call("dnd5e.preActivityConsumption", this, usageConfig, messageConfig) === false ) return false;

      if ( "dnd5e.preItemUsageConsumption" in Hooks.events ) {
        foundry.utils.logCompatibilityWarning(
          "The `dnd5e.preItemUsageConsumption` hook has been deprecated and replaced with `dnd5e.preActivityConsumption`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        const { config, options } = this._createDeprecatedConfigs(usageConfig, {}, messageConfig);
        if ( Hooks.call("dnd5e.preItemUsageConsumption", this.item, config, options) === false ) return false;
        this._applyDeprecatedConfigs(usageConfig, {}, messageConfig, config, options);
      }

      const updates = await this._prepareUsageUpdates(usageConfig);
      if ( !updates ) return false;

      /**
       * A hook event that fires after an item's resource consumption is calculated, but before any updates are
       * performed.
       * @function dnd5e.activityConsumption
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @param {ActivityUsageUpdates} updates                Updates to apply to the actor and other documents.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being activated.
       */
      if ( Hooks.call("dnd5e.activityConsumption", this, usageConfig, messageConfig, updates) === false ) return false;

      if ( "dnd5e.itemUsageConsumption" in Hooks.events ) {
        foundry.utils.logCompatibilityWarning(
          "The `dnd5e.itemUsageConsumption` hook has been deprecated and replaced with `dnd5e.activityConsumption`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        const { config, options } = this._createDeprecatedConfigs(usageConfig, {}, messageConfig);
        const usage = {
          actorUpdates: updates.actor,
          deleteIds: updates.delete,
          itemUpdates: updates.item.find(i => i._id === this.item.id),
          resourceUpdates: updates.item.filter(i => i._id !== this.item.id)
        };
        if ( Hooks.call("dnd5e.itemUsageConsumption", this.item, config, options, usage) === false ) return false;
        this._applyDeprecatedConfigs(usageConfig, {}, messageConfig, config, options);
        updates.actor = usage.actorUpdates;
        updates.delete = usage.deleteIds;
        updates.item = usage.resourceUpdates;
        if ( !foundry.utils.isEmpty(usage.itemUpdates) ) updates.item.push({ _id: this.item.id, ...usage.itemUpdates });
      }

      const consumed = await this.#applyUsageUpdates(updates);
      if ( !foundry.utils.isEmpty(consumed) ) {
        foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.consumed", consumed);
      }
      if ( usageConfig.cause?.activity ) {
        foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.cause", usageConfig.cause.activity);
      }

      /**
       * A hook event that fires after an item's resource consumption is calculated and applied.
       * @function dnd5e.postActivityConsumption
       * @memberof hookEvents
       * @param {Activity} activity                           Activity being activated.
       * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
       * @param {ActivityMessageConfiguration} messageConfig  Configuration info for the created chat message.
       * @param {ActivityUsageUpdates} updates                Applied updates to the actor and other documents.
       * @returns {boolean}  Explicitly return `false` to prevent activity from being activated.
       */
      if ( Hooks.call("dnd5e.postActivityConsumption", this, usageConfig, messageConfig, updates) === false ) return false;

      return updates;
    }

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

    /**
     * @typedef ActivityConsumptionDescriptor
     * @property {{ keyPath: string, delta: number }[]} actor                 Changes for the actor.
     * @property {Record<string, { keyPath: string, delta: number }[]>} item  Changes for each item grouped by ID.
     */

    /**
     * Refund previously used consumption for an activity.
     * @param {ActivityConsumptionDescriptor} consumed  Data on the consumption that occurred.
     */
    async refund(consumed) {
      const updates = {
        activity: {}, actor: {}, create: consumed.deleted ?? [], delete: consumed.created ?? [], item: []
      };
      for ( const { keyPath, delta } of consumed.actor ?? [] ) {
        const value = foundry.utils.getProperty(this.actor, keyPath) - delta;
        if ( !Number.isNaN(value) ) updates.actor[keyPath] = value;
      }
      for ( const [id, changes] of Object.entries(consumed.item ?? {}) ) {
        const item = this.actor.items.get(id);
        if ( !item ) continue;
        const itemUpdate = {};
        for ( const { keyPath, delta } of changes ) {
          let currentValue;
          if ( keyPath.startsWith("system.activities") ) {
            const [id, ...kp] = keyPath.slice(18).split(".");
            currentValue = foundry.utils.getProperty(item.system.activities?.get(id) ?? {}, kp.join("."));
          } else currentValue = foundry.utils.getProperty(item, keyPath);
          const value = currentValue - delta;
          if ( !Number.isNaN(value) ) itemUpdate[keyPath] = value;
        }
        if ( !foundry.utils.isEmpty(itemUpdate) ) {
          itemUpdate._id = id;
          updates.item.push(itemUpdate);
        }
      }
      await this.#applyUsageUpdates(updates);
    }

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

    /**
     * Merge activity updates into the appropriate item updates and apply.
     * @param {ActivityUsageUpdates} updates
     * @returns {ActivityConsumptionDescriptor}  Information on consumption performed to store in message flag.
     */
    async #applyUsageUpdates(updates) {
      this._mergeActivityUpdates(updates);

      // Ensure no existing items are created again & no non-existent items try to be deleted
      updates.create = updates.create?.filter(i => !this.actor.items.has(i));
      updates.delete = updates.delete?.filter(i => this.actor.items.has(i));

      // Create the consumed flag
      const getDeltas = (document, updates) => {
        updates = foundry.utils.flattenObject(updates);
        return Object.entries(updates).map(([keyPath, value]) => {
          let currentValue;
          if ( keyPath.startsWith("system.activities") ) {
            const [id, ...kp] = keyPath.slice(18).split(".");
            currentValue = foundry.utils.getProperty(document.system.activities?.get(id) ?? {}, kp.join("."));
          } else currentValue = foundry.utils.getProperty(document, keyPath);
          const delta = value - currentValue;
          if ( delta && !Number.isNaN(delta) ) return { keyPath, delta };
          return null;
        }).filter(_ => _);
      };
      const consumed = {
        actor: getDeltas(this.actor, updates.actor),
        item: updates.item.reduce((obj, { _id, ...changes }) => {
          const deltas = getDeltas(this.actor.items.get(_id), changes);
          if ( deltas.length ) obj[_id] = deltas;
          return obj;
        }, {})
      };
      if ( foundry.utils.isEmpty(consumed.actor) ) delete consumed.actor;
      if ( foundry.utils.isEmpty(consumed.item) ) delete consumed.item;
      if ( updates.create?.length ) consumed.created = updates.create;
      if ( updates.delete?.length ) consumed.deleted = updates.delete.map(i => this.actor.items.get(i).toObject());

      // Update documents with consumption
      if ( !foundry.utils.isEmpty(updates.actor) ) await this.actor.update(updates.actor);
      if ( !foundry.utils.isEmpty(updates.create) ) {
        await this.actor.createEmbeddedDocuments("Item", updates.create, { keepId: true });
      }
      if ( !foundry.utils.isEmpty(updates.delete) ) await this.actor.deleteEmbeddedDocuments("Item", updates.delete);
      if ( !foundry.utils.isEmpty(updates.item) ) await this.actor.updateEmbeddedDocuments("Item", updates.item);

      return consumed;
    }

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

    /**
     * Translate new config objects back into old config objects for deprecated hooks.
     * @param {ActivityUseConfiguration} usageConfig
     * @param {ActivityDialogConfiguration} dialogConfig
     * @param {ActivityMessageConfiguration} messageConfig
     * @returns {{ config: ItemUseConfiguration, options: ItemUseOptions }}
     * @internal
     */
    _createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig) {
      let consumeResource;
      let consumeUsage;
      if ( (usageConfig.consume === true) || (usageConfig.consume?.resources === true) ) {
        consumeResource = consumeUsage = true;
      } else if ( (usageConfig.consume === false) || (usageConfig.comsume?.resources === false) ) {
        consumeResource = consumeUsage = false;
      } else if ( foundry.utils.getType(usageConfig.consume?.resources) === "Array" ) {
        for ( const index of usageConfig.consume.resources ) {
          if ( ["activityUses", "itemUses"].includes(this.consumption.targets[index]?.type) ) consumeUsage = true;
          else consumeResource = true;
        }
      }
      return {
        config: {
          createMeasuredTemplate: usageConfig.create?.measuredTemplate ?? null,
          consumeResource,
          consumeSpellSlot: usageConfig.consume?.spellSlot !== false ?? null,
          consumeUsage,
          slotLevel: usageConfig.spell?.slot ?? null,
          resourceAmount: usageConfig.scaling ?? null,
          beginConcentrating: usageConfig.concentration?.begin ?? false,
          endConcentration: usageConfig.concentration?.end ?? null
        },
        options: {
          configureDialog: dialogConfig.configure,
          rollMode: messageConfig.rollMode,
          createMessage: messageConfig.create,
          flags: messageConfig.data?.flags,
          event: usageConfig.event
        }
      };
    }

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

    /**
     * Apply changes from old config objects back onto new config objects.
     * @param {ActivityUseConfiguration} usageConfig
     * @param {ActivityDialogConfiguration} dialogConfig
     * @param {ActivityMessageConfiguration} messageConfig
     * @param {ItemUseConfiguration} config
     * @param {ItemUseOptions} options
     * @internal
     */
    _applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options) {
      const { resourceIndices, usageIndices } = this.consumption.targets.reduce((o, data, index) => {
        if ( ["activityUses", "itemUses"].includes(data.type) ) o.usageIndices.push(index);
        else o.resourceIndices.push(index);
        return o;
      }, { resourceIndices: [], usageIndices: [] });
      let resources;
      if ( config.consumeResource && config.consumeUsage ) resources = true;
      else if ( config.consumeResource && (config.consumeUsage === false) ) resources = resourceIndices;
      else if ( (config.consumeResource === false) && config.consumeUsage ) resources = usageIndices;

      // Set property so long as the value is not undefined
      // Avoids problems with `mergeObject` overwriting values with `undefined`
      const set = (config, keyPath, value) => {
        if ( value === undefined ) return;
        foundry.utils.setProperty(config, keyPath, value);
      };

      set(usageConfig, "create.measuredTemplate", config.createMeasuredTemplate);
      set(usageConfig, "concentration.begin", config.beginConcentrating);
      set(usageConfig, "concentration.end", config.endConcentration);
      set(usageConfig, "consume.resources", resources);
      set(usageConfig, "consume.spellSlot", config.consumeSpellSlot);
      set(usageConfig, "scaling", config.resourceAmount);
      set(usageConfig, "spell.slot", config.slotLevel);
      set(dialogConfig, "configure", options.configureDialog);
      set(messageConfig, "create", options.createMessage);
      set(messageConfig, "rollMode", options.rollMode);
      if ( options.flags ) {
        messageConfig.data ??= {};
        messageConfig.data.flags = foundry.utils.mergeObject(messageConfig.data.flags ?? {}, options.flags);
      }
    }

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

    /**
     * Prepare usage configuration with the necessary defaults.
     * @param {ActivityUseConfiguration} config  Configuration object passed to the `use` method.
     * @returns {ActivityUseConfiguration}
     * @protected
     */
    _prepareUsageConfig(config) {
      config = foundry.utils.deepClone(config);
      const linked = this.getLinkedActivity(config.cause?.activity);

      if ( config.create !== false ) {
        config.create ??= {};
        config.create.measuredTemplate ??= !!this.target.template.type && this.target.prompt;
        // TODO: Handle permissions checks in `ActivityUsageDialog`
      }

      const ignoreLinkedConsumption = this.isSpell && !this.consumption.spellSlot;
      if ( config.consume !== false ) {
        const hasActionConsumption = this.activation.type === "legendary";
        const hasResourceConsumption = this.consumption.targets.length > 0;
        const hasLinkedConsumption = (linked?.consumption.targets.length > 0) && !ignoreLinkedConsumption;
        const hasSpellSlotConsumption = this.requiresSpellSlot && this.consumption.spellSlot;
        config.consume ??= {};
        config.consume.action ??= hasActionConsumption;
        config.consume.resources ??= Array.from(this.consumption.targets.entries())
          .filter(([, target]) => !target.combatOnly || this.actor.inCombat)
          .map(([index]) => index);
        config.consume.spellSlot ??= !linked && hasSpellSlotConsumption;
        config.hasConsumption = hasActionConsumption || hasResourceConsumption || hasLinkedConsumption
          || (!linked && hasSpellSlotConsumption);
      }

      const levelingFlag = this.item.getFlag("dnd5e", "spellLevel");
      if ( levelingFlag ) {
        // Handle fixed scaling from spell scrolls
        config.scaling = false;
        config.spell ??= {};
        config.spell.slot = levelingFlag.value;
      }

      else {
        const canScale = linked ? linked.consumption.scaling.allowed : this.canScale;
        const linkedDelta = (linked?.spell?.level ?? Infinity) - this.item.system.level;
        if ( canScale ) config.scaling ??= Number.isFinite(linkedDelta) ? linkedDelta : 0;
        else config.scaling = false;

        if ( this.requiresSpellSlot ) {
          const mode = this.item.system.preparation.mode;
          config.spell ??= {};
          config.spell.slot ??= linked?.spell?.level ? `spell${linked.spell.level}`
            : (mode in this.actor.system.spells) ? mode : `spell${this.item.system.level}`;
        }
      }

      if ( this.requiresConcentration && !game.settings.get("dnd5e", "disableConcentration") ) {
        config.concentration ??= {};
        config.concentration.begin ??= true;
        const { effects } = this.actor.concentration;
        const limit = this.actor.system.attributes?.concentration?.limit ?? 0;
        if ( limit && (limit <= effects.size) ) config.concentration.end ??= effects.find(e => {
          const data = e.flags.dnd5e?.item?.data ?? {};
          return (data === this.id) || (data._id === this.id);
        })?.id ?? effects.first()?.id ?? null;
      }

      if ( linked ) {
        config.cause ??= {};
        config.cause.activity ??= linked.relativeUUID;
        config.cause.resources ??= (linked.consumption.targets.length > 0) && !ignoreLinkedConsumption;
      }

      return config;
    }

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

    /**
     * Determine scaling values and update item clone if necessary.
     * @param {ActivityUseConfiguration} usageConfig        Configuration data for the activation.
     * @param {ActivityMessageConfiguration} messageConfig  Configuration data for the chat message.
     * @param {Item5e} item                                 Clone of the item that contains this activity.
     * @protected
     */
    async _prepareUsageScaling(usageConfig, messageConfig, item) {
      const levelingFlag = this.item.getFlag("dnd5e", "spellLevel");
      if ( levelingFlag ) {
        usageConfig.scaling = Math.max(0, levelingFlag.value - levelingFlag.base);
      } else if ( this.isSpell ) {
        const level = this.actor.system.spells?.[usageConfig.spell?.slot]?.level;
        if ( level ) {
          usageConfig.scaling = level - item.system.level;
          foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.use.spellLevel", level);
        }
      }

      if ( usageConfig.scaling ) {
        foundry.utils.setProperty(messageConfig, "data.flags.dnd5e.scaling", usageConfig.scaling);
        item.updateSource({ "flags.dnd5e.scaling": usageConfig.scaling });
        item.prepareFinalAttributes();
      }
    }

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

    /**
     * Update data produced by activity usage.
     *
     * @typedef {object} ActivityUsageUpdates
     * @property {object} activity  Updates applied to activity that performed the activation.
     * @property {object} actor     Updates applied to the actor that performed the activation.
     * @property {object[]} create  Full data for Items to create (with IDs maintained).
     * @property {string[]} delete  IDs of items to be deleted from the actor.
     * @property {object[]} item    Updates applied to items on the actor that performed the activation.
     * @property {Roll[]} rolls     Any rolls performed as part of the activation.
     */

    /**
     * Calculate changes to actor, items, & this activity based on resource consumption.
     * @param {ActivityUseConfiguration} config                  Usage configuration.
     * @param {object} [options={}]
     * @param {boolean} [options.returnErrors=false]             Return array of errors, rather than displaying them.
     * @returns {ActivityUsageUpdates|ConsumptionError[]|false}  Updates to perform, an array of ConsumptionErrors,
     *                                                           or `false` if a consumption error occurred.
     * @protected
     */
    async _prepareUsageUpdates(config, { returnErrors=false }={}) {
      const updates = { activity: {}, actor: {}, create: [], delete: [], item: [], rolls: [] };
      if ( config.consume === false ) return updates;
      const errors = [];

      // Handle action economy
      if ( ((config.consume === true) || config.consume.action) && (this.activation.type === "legendary") ) {
        const containsLegendaryConsumption = this.consumption.targets
          .find(t => (t.type === "attribute") && (t.target === "resources.legact.value"));
        const count = this.activation.value ?? 1;
        const legendary = this.actor.system.resources?.legact;
        if ( legendary && !containsLegendaryConsumption ) {
          let message;
          if ( legendary.value === 0 ) message = "DND5E.ACTIVATION.Warning.NoActions";
          else if ( count > legendary.value ) message = "DND5E.ACTIVATION.Warning.NotEnoughActions";
          if ( message ) {
            const err = new ConsumptionError(game.i18n.format(message, {
              type: game.i18n.localize("DND5E.LegendaryAction.Label"),
              required: formatNumber(count),
              available: formatNumber(legendary.value)
            }));
            errors.push(err);
          } else {
            updates.actor["system.resources.legact.value"] = legendary.value - count;
          }
        }
      }

      // Handle consumption targets
      if ( (config.consume === true) || config.consume.resources ) {
        const indexes = (config.consume === true) || (config.consume.resources === true)
          ? this.consumption.targets.keys() : config.consume.resources;
        for ( const index of indexes ) {
          const target = this.consumption.targets[index];
          try {
            await target.consume(config, updates);
          } catch(err) {
            if ( err instanceof ConsumptionError ) errors.push(err);
            else throw err;
          }
        }
      }

      // Handle consumption on a linked activity
      if ( config.cause ) {
        const linkedActivity = this.getLinkedActivity(config.cause.activity);
        if ( linkedActivity ) {
          const consume = {
            resources: (config.consume === true) || (config.cause?.resources === true)
              ? linkedActivity.consumption.targets.keys() : config.cause?.resources,
            spellSlot: false
          };
          const usageConfig = foundry.utils.mergeObject(config, { consume, cause: false }, { inplace: false });
          const results = await linkedActivity._prepareUsageUpdates(usageConfig, { returnErrors: true });
          if ( foundry.utils.getType(results) === "Object" ) {
            linkedActivity._mergeActivityUpdates(results);
            foundry.utils.mergeObject(updates.actor, results.actor);
            updates.delete.push(...results.delete);
            updates.item.push(...results.item);
            updates.rolls.push(...results.rolls);
            // Mark this item for deletion if it is linked to a cast activity that will be deleted
            if ( updates.delete.includes(linkedActivity.item.id)
              && (this.item.getFlag("dnd5e", "cachedFor") === linkedActivity.relativeUUID) ) {
              updates.delete.push(this.item.id);
            }
          } else if ( results?.length ) {
            errors.push(...results);
          }
        }
      }

      // Handle spell slot consumption
      else if ( ((config.consume === true) || config.consume.spellSlot)
        && this.requiresSpellSlot && this.consumption.spellSlot ) {
        const mode = this.item.system.preparation.mode;
        const isLeveled = ["always", "prepared"].includes(mode);
        const effectiveLevel = this.item.system.level + (config.scaling ?? 0);
        const slot = config.spell?.slot ?? (isLeveled ? `spell${effectiveLevel}` : mode);
        const slotData = this.actor.system.spells?.[slot];
        if ( slotData ) {
          if ( slotData.value ) {
            const newValue = Math.max(slotData.value - 1, 0);
            foundry.utils.mergeObject(updates.actor, { [`system.spells.${slot}.value`]: newValue });
          } else {
            const err = new ConsumptionError(game.i18n.format("DND5E.SpellCastNoSlots", {
              name: this.item.name, level: slotData.label
            }));
            errors.push(err);
          }
        }
      }

      // Ensure concentration can be handled
      if ( config.concentration?.begin ) {
        const { effects } = this.actor.concentration;
        // Ensure existing concentration effect exists when replacing concentration
        if ( config.concentration.end ) {
          const replacedEffect = effects.find(i => i.id === config.concentration.end);
          if ( !replacedEffect ) errors.push(
            new ConsumptionError(game.i18n.localize("DND5E.ConcentratingMissingItem"))
          );
        }

        // Cannot begin more concentrations than the limit
        else if ( effects.size >= this.actor.system.attributes?.concentration?.limit ) errors.push(
          new ConsumptionError(game.i18n.localize("DND5E.ConcentratingLimited"))
        );
      }

      if ( !returnErrors ) errors.forEach(err => ui.notifications.error(err.message, { console: false }));
      return errors.length ? returnErrors ? errors : false : updates;
    }

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

    /**
     * Determine if the configuration dialog is required based on the configuration options. Does not guarantee a dialog
     * is shown if the dialog is suppressed in the activation dialog configuration.
     * @param {ActivityUseConfiguration} config
     * @returns {boolean}
     * @protected
     */
    _requiresConfigurationDialog(config) {
      const checkObject = obj => (foundry.utils.getType(obj) === "Object")
        && Object.values(obj).some(v => v === true || v?.length);
      return config.concentration?.begin === true
        || checkObject(config.create)
        || ((checkObject(config.consume) || (config.cause?.resources === true)) && config.hasConsumption)
        || (config.scaling !== false);
    }

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

    /**
     * Prepare the context used to render the usage chat card.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
     * @returns {object}
     * @protected
     */
    async _usageChatContext(message) {
      const data = await this.item.system.getCardData({ activity: this });
      const properties = [...(data.tags ?? []), ...(data.properties ?? [])];
      const supplements = [];
      if ( this.activation.condition ) {
        supplements.push(`<strong>${game.i18n.localize("DND5E.Trigger")}</strong> ${this.activation.condition}`);
      }
      if ( data.materials?.value ) {
        supplements.push(`<strong>${game.i18n.localize("DND5E.Materials")}</strong> ${data.materials.value}`);
      }
      const buttons = this._usageChatButtons(message);

      // Include spell level in the subtitle.
      if ( this.item.type === "spell" ) {
        const spellLevel = foundry.utils.getProperty(message, "data.flags.dnd5e.use.spellLevel");
        const { spellLevels, spellSchools } = CONFIG.DND5E;
        data.subtitle = [spellLevels[spellLevel], spellSchools[this.item.system.school]?.label].filterJoin(" &bull; ");
      }

      return {
        activity: this,
        actor: this.item.actor,
        item: this.item,
        token: this.item.actor?.token,
        buttons: buttons.length ? buttons : null,
        description: data.description,
        properties: properties.length ? properties : null,
        subtitle: this.description.chatFlavor || data.subtitle,
        supplements
      };
    }

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

    /**
     * @typedef {object} ActivityUsageChatButton
     * @property {string} label    Label to display on the button.
     * @property {string} icon     Icon to display on the button.
     * @property {string} classes  Classes for the button.
     * @property {object} dataset  Data attributes attached to the button.
     */

    /**
     * Create the buttons that will be displayed in chat.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
     * @returns {ActivityUsageChatButton[]}
     * @protected
     */
    _usageChatButtons(message) {
      const buttons = [];

      if ( this.target?.template?.type ) buttons.push({
        label: game.i18n.localize("DND5E.TARGET.Action.PlaceTemplate"),
        icon: '<i class="fas fa-bullseye" inert></i>',
        dataset: {
          action: "placeTemplate"
        }
      });

      if ( message.hasConsumption ) buttons.push({
        label: game.i18n.localize("DND5E.CONSUMPTION.Action.ConsumeResource"),
        icon: '<i class="fa-solid fa-cubes-stacked" inert></i>',
        dataset: {
          action: "consumeResource"
        }
      }, {
        label: game.i18n.localize("DND5E.CONSUMPTION.Action.RefundResource"),
        icon: '<i class="fa-solid fa-clock-rotate-left"></i>',
        dataset: {
          action: "refundResource"
        }
      });

      return buttons;
    }

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

    /**
     * Determine whether the provided button in a chat message should be visible.
     * @param {HTMLButtonElement} button  The button to check.
     * @param {ChatMessage5e} message     Chat message containing the button.
     * @returns {boolean}
     */
    shouldHideChatButton(button, message) {
      const flag = message.getFlag("dnd5e", "use.consumed");
      switch ( button.dataset.action ) {
        case "consumeResource": return !!flag;
        case "refundResource": return !flag;
        case "placeTemplate": return !game.user.can("TEMPLATE_CREATE") || !game.canvas.scene;
      }
      return false;
    }

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

    /**
     * Display a chat message for this usage.
     * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
     * @returns {Promise<ChatMessage5e|object>}
     * @protected
     */
    async _createUsageMessage(message) {
      const context = await this._usageChatContext(message);
      const messageConfig = foundry.utils.mergeObject({
        rollMode: game.settings.get("core", "rollMode"),
        data: {
          content: await renderTemplate(this.metadata.usage.chatCard, context),
          speaker: ChatMessage.getSpeaker({ actor: this.item.actor }),
          flags: {
            core: { canPopout: true }
          }
        }
      }, message);

      /**
       * A hook event that fires before an activity usage card is created.
       * @function dnd5e.preCreateUsageMessage
       * @memberof hookEvents
       * @param {Activity} activity                     Activity for which the card will be created.
       * @param {ActivityMessageConfiguration} message  Configuration info for the created message.
       */
      Hooks.callAll("dnd5e.preCreateUsageMessage", this, messageConfig);

      ChatMessage.applyRollMode(messageConfig.data, messageConfig.rollMode);
      const card = messageConfig.create === false ? messageConfig.data : await ChatMessage.create(messageConfig.data);

      /**
       * A hook event that fires after an activity usage card is created.
       * @function dnd5e.postCreateUsageMessage
       * @memberof hookEvents
       * @param {Activity} activity          Activity for which the card was created.
       * @param {ChatMessage5e|object} card  Created card or configuration data if not created.
       */
      Hooks.callAll("dnd5e.postCreateUsageMessage", this, card);

      return card;
    }

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

    /**
     * Perform any final steps of the activation including creating measured templates.
     * @param {ActivityUseConfiguration} config  Configuration data for the activation.
     * @param {ActivityUsageResults} results     Final details on the activation.
     * @protected
     */
    async _finalizeUsage(config, results) {
      results.templates = config.create?.measuredTemplate ? await this.#placeTemplate() : [];
    }

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

    /**
     * Trigger a primary activation action defined by the activity (such as opening the attack dialog for attack rolls).
     * @param {ActivityUseConfiguration} config  Configuration data for the activation.
     * @param {ActivityUsageResults} results     Final details on the activation.
     * @protected
     */
    async _triggerSubsequentActions(config, results) {}

    /* -------------------------------------------- */
    /*  Rolling                                     */
    /* -------------------------------------------- */

    /**
     * Perform a damage roll.
     * @param {Partial<DamageRollProcessConfiguration>} config  Configuration information for the roll.
     * @param {Partial<BasicRollDialogConfiguration>} dialog    Configuration for the roll dialog.
     * @param {Partial<BasicRollMessageConfiguration>} message  Configuration for the roll message.
     * @returns {Promise<DamageRoll[]|void>}
     */
    async rollDamage(config={}, dialog={}, message={}) {
      const rollConfig = this.getDamageConfig(config);
      rollConfig.hookNames = [...(config.hookNames ?? []), "damage"];
      rollConfig.subject = this;

      const dialogConfig = foundry.utils.mergeObject({
        options: {
          position: {
            width: 400,
            top: config.event ? config.event.clientY - 80 : null,
            left: window.innerWidth - 710
          },
          window: {
            title: this.damageFlavor,
            subtitle: this.item.name,
            icon: this.item.img
          }
        }
      }, dialog);

      const messageConfig = foundry.utils.mergeObject({
        create: true,
        data: {
          flavor: `${this.item.name} - ${this.damageFlavor}`,
          flags: {
            dnd5e: {
              ...this.messageFlags,
              messageType: "roll",
              roll: { type: "damage" }
            }
          },
          speaker: ChatMessage.getSpeaker({ actor: this.actor })
        }
      }, message);

      let returnMultiple = rollConfig.returnMultiple ?? true;
      if ( "dnd5e.preRollDamage" in Hooks.events ) {
        foundry.utils.logCompatibilityWarning(
          "The `dnd5e.preRollDamage` hook has been deprecated and replaced with `dnd5e.preRollDamageV2`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        const oldRollConfig = {
          actor: this.actor,
          rollConfigs: rollConfig.rolls.map((r, _index) => ({
            _index,
            parts: r.parts,
            type: r.options?.type,
            types: r.options?.types,
            properties: r.options?.properties
          })),
          data: rollConfig.rolls[0]?.data ?? {},
          event: rollConfig.event,
          returnMultiple,
          allowCritical: rollConfig.rolls[0]?.critical?.allow ?? rollConfig.critical?.allow ?? true,
          critical: rollConfig.rolls[0]?.isCritical,
          criticalBonusDice: rollConfig.rolls[0]?.critical?.bonusDice ?? rollConfig.critical?.bonusDice,
          criticalMultiplier: rollConfig.rolls[0]?.critical?.multiplier ?? rollConfig.critical?.multiplier,
          multiplyNumeric: rollConfig.rolls[0]?.critical?.multiplyNumeric ?? rollConfig.critical?.multiplyNumeric,
          powerfulCritical: rollConfig.rolls[0]?.critical?.powerfulCritical ?? rollConfig.critical?.powerfulCritical,
          criticalBonusDamage: rollConfig.rolls[0]?.critical?.bonusDamage ?? rollConfig.critical?.bonusDamage,
          title: `${this.item.name} - ${this.damageFlavor}`,
          dialogOptions: dialogConfig.options,
          chatMessage: messageConfig.create,
          messageData: messageConfig.data,
          rollMode: messageConfig.rollMode,
          flavor: messageConfig.data.flavor
        };
        if ( "configure" in dialogConfig ) oldRollConfig.fastForward = !dialogConfig.configure;
        if ( Hooks.call("dnd5e.preRollDamage", this.item, oldRollConfig) === false ) return;
        rollConfig.rolls = rollConfig.rolls.map((roll, index) => {
          const otherConfig = oldRollConfig.rollConfigs.find(r => r._index === index);
          if ( !otherConfig ) return null;
          roll.data = oldRollConfig.data;
          roll.parts = otherConfig.parts;
          roll.isCritical = oldRollConfig.critical;
          roll.options.type = otherConfig.type;
          roll.options.types = otherConfig.types;
          roll.options.properties = otherConfig.properties;
          return roll;
        }, [])
          .filter(_ => _)
          .concat(oldRollConfig.rollConfigs.filter(r => r._index === undefined));
        returnMultiple = oldRollConfig.returnMultiple;
        rollConfig.critical ??= {};
        rollConfig.critical.allow = oldRollConfig.allowCritical;
        if ( "fastForward" in oldRollConfig ) dialogConfig.configure = !oldRollConfig.fastForward;
        dialogConfig.options = oldRollConfig.dialogOptions;
        messageConfig.create = oldRollConfig.chatMessage;
        messageConfig.data = oldRollConfig.messageData;
        messageConfig.rollMode = oldRollConfig.rollMode;
        messageConfig.data.flavor = oldRollConfig.flavor;
      }

      const rolls = await CONFIG.Dice.DamageRoll.build(rollConfig, dialogConfig, messageConfig);
      if ( !rolls?.length ) return;
      const lastDamageTypes = rolls.reduce((obj, roll, index) => {
        if ( roll.options.type ) obj[index] = roll.options.type;
        return obj;
      }, {});
      if ( !foundry.utils.isEmpty(lastDamageTypes) && this.actor.items.has(this.item.id) ) {
        await this.item.setFlag("dnd5e", `last.${this.id}.damageType`, lastDamageTypes);
      }

      /**
       * A hook event that fires after damage has been rolled.
       * @function dnd5e.rollDamageV2
       * @memberof hookEvents
       * @param {DamageRoll[]} rolls       The resulting rolls.
       * @param {object} [data]
       * @param {Activity} [data.subject]  The activity that performed the roll.
       */
      Hooks.callAll("dnd5e.rollDamageV2", rolls, { subject: this });

      if ( "dnd5e.rollDamage" in Hooks.events ) {
        foundry.utils.logCompatibilityWarning(
          "The `dnd5e.rollDamage` hook has been deprecated and replaced with `dnd5e.rollDamageV2`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        Hooks.callAll("dnd5e.rollDamage", this.item, returnMultiple ? rolls : rolls[0]);
      }

      return rolls;
    }

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

    /**
     * Activate listeners on a chat message.
     * @param {ChatMessage} message  Associated chat message.
     * @param {HTMLElement} html     Element in the chat log.
     */
    activateChatListeners(message, html) {
      html.addEventListener("click", event => {
        const target = event.target.closest("[data-action]");
        if ( target ) this.#onChatAction(event, target, message);
      });
    }

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

    /**
     * Construct context menu options for this Activity.
     * @returns {ContextMenuEntry[]}
     */
    getContextMenuOptions() {
      const entries = [];
      const compendiumLocked = this.item[game.release.generation < 13 ? "compendium" : "collection"]?.locked;

      if ( this.item.isOwner && !compendiumLocked ) {
        entries.push({
          name: "DND5E.ContextMenuActionEdit",
          icon: '<i class="fas fa-pen-to-square fa-fw"></i>',
          callback: () => this.sheet.render({ force: true })
        }, {
          name: "DND5E.ContextMenuActionDuplicate",
          icon: '<i class="fas fa-copy fa-fw"></i>',
          callback: () => {
            const createData = this.toObject();
            delete createData._id;
            this.item.createActivity(createData.type, createData, { renderSheet: false });
          }
        }, {
          name: "DND5E.ContextMenuActionDelete",
          icon: '<i class="fas fa-trash fa-fw"></i>',
          callback: () => this.deleteDialog()
        });
      } else {
        entries.push({
          name: "DND5E.ContextMenuActionView",
          icon: '<i class="fas fa-eye fa-fw"></i>',
          callback: () => this.sheet.render({ force: true })
        });
      }

      if ( "favorites" in (this.actor?.system ?? {}) ) {
        const uuid = `${this.item.getRelativeUUID(this.actor)}.Activity.${this.id}`;
        const isFavorited = this.actor.system.hasFavorite(uuid);
        entries.push({
          name: isFavorited ? "DND5E.FavoriteRemove" : "DND5E.Favorite",
          icon: '<i class="fas fa-bookmark fa-fw"></i>',
          condition: () => this.item.isOwner && !compendiumLocked,
          callback: () => {
            if ( isFavorited ) this.actor.system.removeFavorite(uuid);
            else this.actor.system.addFavorite({ type: "activity", id: uuid });
          },
          group: "state"
        });
      }

      return entries;
    }

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

    /**
     * Handle an action activated from an activity's chat message.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     */
    async #onChatAction(event, target, message) {
      const scaling = message.getFlag("dnd5e", "scaling") ?? 0;
      const item = scaling ? this.item.clone({ "flags.dnd5e.scaling": scaling }, { keepId: true }) : this.item;
      const activity = item.system.activities.get(this.id);

      const action = target.dataset.action;
      const handler = this.metadata.usage?.actions?.[action];
      target.disabled = true;
      try {
        if ( handler ) await handler.call(activity, event, target, message);
        else if ( action === "consumeResource" ) await this.#consumeResource(event, target, message);
        else if ( action === "refundResource" ) await this.#refundResource(event, target, message);
        else if ( action === "placeTemplate" ) await this.#placeTemplate();
        else await activity._onChatAction(event, target, message);
      } catch(err) {
        Hooks.onError("Activity#onChatAction", err, { log: "error", notify: "error" });
      } finally {
        target.disabled = false;
      }
    }

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

    /**
     * Handle an action activated from an activity's chat message. Action handlers in metadata are called first.
     * This method is only called for actions which have no defined handler.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     * @protected
     */
    async _onChatAction(event, target, message) {}

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

    /**
     * Handle context menu events on activities.
     * @param {Item5e} item         The Item the Activity belongs to.
     * @param {HTMLElement} target  The element the menu was triggered on.
     */
    static onContextMenu(item, target) {
      const { activityId } = target.closest("[data-activity-id]")?.dataset ?? {};
      const activity = item.system.activities?.get(activityId);
      if ( !activity ) return;
      const menuItems = activity.getContextMenuOptions();

      /**
       * A hook even that fires when the context menu for an Activity is opened.
       * @function dnd5e.getItemActivityContext
       * @memberof hookEvents
       * @param {Activity} activity             The Activity.
       * @param {HTMLElement} target            The element that menu was triggered on.
       * @param {ContextMenuEntry[]} menuItems  The context menu entries.
       */
      Hooks.callAll("dnd5e.getItemActivityContext", activity, target, menuItems);
      ui.context.menuItems = menuItems;
    }

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

    /**
     * Handle consuming resources from the chat card.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     */
    async #consumeResource(event, target, message) {
      const messageConfig = {};
      const scaling = message.getFlag("dnd5e", "scaling");
      const usageConfig = { consume: true, event, scaling };
      const linkedActivity = this.getLinkedActivity(message.getFlag("dnd5e", "use.cause"));
      if ( linkedActivity ) usageConfig.cause = {
        activity: linkedActivity.relativeUUID, resources: linkedActivity.consumption.targets.length > 0
      };
      await this.consume(usageConfig, messageConfig);
      if ( !foundry.utils.isEmpty(messageConfig.data) ) await message.update(messageConfig.data);
    }

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

    /**
     * Handle refunding consumption from a chat card.
     * @param {PointerEvent} event     Triggering click event.
     * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
     * @param {ChatMessage5e} message  Message associated with the activation.
     */
    async #refundResource(event, target, message) {
      const consumed = message.getFlag("dnd5e", "use.consumed");
      if ( !foundry.utils.isEmpty(consumed) ) {
        await this.refund(consumed);
        await message.unsetFlag("dnd5e", "use.consumed");
      }
    }

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

    /**
     * Handle placing a measured template in the scene.
     * @returns {MeasuredTemplateDocument[]}
     */
    async #placeTemplate() {
      const templates = [];
      try {
        for ( const template of AbilityTemplate.fromActivity(this) ) {
          const result = await template.drawPreview();
          if ( result ) templates.push(result);
        }
      } catch(err) {
        Hooks.onError("Activity#placeTemplate", err, {
          msg: game.i18n.localize("DND5E.TARGET.Warning.PlaceTemplate"),
          log: "error",
          notify: "error"
        });
      }
      return templates;
    }

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

    /**
     * Prepare activity favorite data.
     * @returns {Promise<FavoriteData5e>}
     */
    async getFavoriteData() {
      return {
        img: this.img,
        title: this.name,
        subtitle: [this.labels.activation, this.labels.recovery],
        range: this.range,
        uses: { ...this.uses, name: "uses.value" }
      };
    }

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

    /**
     * Retrieve a linked activity based on the provided relative UUID, or the stored `cachedFor` value.
     * @param {string} relativeUUID  Relative UUID for an activity on this actor.
     * @returns {Activity|null}
     */
    getLinkedActivity(relativeUUID) {
      if ( !this.actor ) return null;
      relativeUUID ??= this.item.getFlag("dnd5e", "cachedFor");
      return fromUuidSync(relativeUUID, { relative: this.actor, strict: false });
    }

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

    /**
     * Prepare a data object which defines the data schema used by dice roll commands against this Activity.
     * @param {object} [options]
     * @param {boolean} [options.deterministic]  Whether to force deterministic values for data properties that could
     *                                           be either a die term or a flat term.
     * @returns {object}
     */
    getRollData(options) {
      const rollData = this.item.getRollData(options);
      rollData.activity = { ...this };
      rollData.mod = this.actor?.system.abilities?.[this.ability]?.mod ?? 0;
      return rollData;
    }

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

    /**
     * Merge the activity updates into this activity's item updates.
     * @param {ActivityUsageUpdates} updates
     * @internal
     */
    _mergeActivityUpdates(updates) {
      if ( foundry.utils.isEmpty(updates.activity) ) return;
      const itemIndex = updates.item.findIndex(i => i._id === this.item.id);
      const keyPath = `system.activities.${this.id}`;
      const activityUpdates = foundry.utils.expandObject(updates.activity);
      if ( itemIndex === -1 ) updates.item.push({ _id: this.item.id, [keyPath]: activityUpdates });
      else updates.item[itemIndex][keyPath] = activityUpdates;
    }
  }
  return Activity;
}

/**
 * Sheet for the attack activity.
 */
class AttackSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["attack-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    identity: {
      template: "systems/dnd5e/templates/activity/attack-identity.hbs",
      templates: [
        ...super.PARTS.identity.templates,
        "systems/dnd5e/templates/activity/parts/attack-identity.hbs"
      ]
    },
    effect: {
      template: "systems/dnd5e/templates/activity/attack-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/attack-damage.hbs",
        "systems/dnd5e/templates/activity/parts/attack-details.hbs",
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/damage-parts.hbs"
      ]
    }
  };

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);

    const availableAbilities = this.activity.availableAbilities;
    context.abilityOptions = [
      {
        value: "", label: game.i18n.format("DND5E.DefaultSpecific", {
          default: this.activity.attack.type.classification === "spell"
            ? game.i18n.localize("DND5E.Spellcasting").toLowerCase()
            : availableAbilities.size
              ? game.i18n.getListFormatter({ style: "short", type: "disjunction" }).format(
                Array.from(availableAbilities).map(a => CONFIG.DND5E.abilities[a].label.toLowerCase())
              )
              : game.i18n.localize("DND5E.None").toLowerCase()
        })
      },
      { rule: true },
      { value: "none", label: game.i18n.localize("DND5E.None") },
      { value: "spellcasting", label: game.i18n.localize("DND5E.Spellcasting") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({
        value, label: config.label, group: game.i18n.localize("DND5E.Abilities")
      }))
    ];

    context.hasBaseDamage = this.item.system.offersBaseDamage;

    return context;
  }

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

  /** @inheritDoc */
  async _prepareIdentityContext(context) {
    context = await super._prepareIdentityContext(context);

    context.attackTypeOptions = Object.entries(CONFIG.DND5E.attackTypes)
      .map(([value, config]) => ({ value, label: config.label }));
    if ( this.item.system.validAttackTypes?.size ) context.attackTypeOptions.unshift({
      value: "",
      label: game.i18n.format("DND5E.DefaultSpecific", {
        default: game.i18n.getListFormatter({ type: "disjunction" }).format(
          Array.from(this.item.system.validAttackTypes).map(t => CONFIG.DND5E.attackTypes[t].label.toLowerCase())
        )
      })
    });

    context.attackClassificationOptions = Object.entries(CONFIG.DND5E.attackClassifications)
      .map(([value, config]) => ({ value, label: config.label }));
    if ( this.item.system.attackClassification ) context.attackClassificationOptions.unshift({
      value: "",
      label: game.i18n.format("DND5E.DefaultSpecific", {
        default: CONFIG.DND5E.attackClassifications[this.item.system.attackClassification].label.toLowerCase()
      })
    });

    return context;
  }
}

const { DiceTerm: DiceTerm$2 } = foundry.dice.terms;

/**
 * Dialog rendering options for a roll configuration dialog.
 *
 * @typedef {object} BasicRollConfigurationDialogOptions
 * @property {typeof BasicRoll} rollType              Roll type to use when constructing final roll.
 * @property {object} [default]
 * @property {number} [default.rollMode]              Default roll mode to have selected.
 * @property {RollBuildConfigCallback} [buildConfig]  Callback to handle additional build configuration.
 * @property {BasicRollConfigurationDialogRenderOptions} [rendering]
 */

/**
 * @callback RollBuildConfigCallback
 * @param {BasicRollProcessConfiguration} process  Configuration for the entire rolling process.
 * @param {BasicRollConfiguration} config          Configuration for a specific roll.
 * @param {FormDataExtended} [formData]            Any data entered into the rolling prompt.
 * @param {number} index                           Index of the roll within all rolls being prepared.
 */

/**
 * @typedef BasicRollConfigurationDialogRenderOptions
 * @property {object} [dice]
 * @property {number} [dice.max=5]               The maximum number of dice to display in the large dice breakdown. If
 *                                               the given rolls contain more dice than this, then the large breakdown
 *                                               is not shown.
 * @property {Set<string>} [dice.denominations]  Valid die denominations to display in the large dice breakdown. If any
 *                                               of the given rolls contain an invalid denomination, then the large
 *                                               breakdown is not shown.
 */

/**
 * Dialog for configuring one or more rolls.
 *
 * @param {BasicRollProcessConfiguration} [config={}]         Initial roll configuration.
 * @param {BasicRollMessageConfiguration} [message={}]        Message configuration.
 * @param {BasicRollConfigurationDialogOptions} [options={}]  Dialog rendering options.
 */
class RollConfigurationDialog extends Dialog5e {
  constructor(config={}, message={}, options={}) {
    super(options);

    this.#config = config;
    this.#message = message;
    this.#buildRolls(foundry.utils.deepClone(this.#config));
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["roll-configuration"],
    window: {
      title: "DND5E.RollConfiguration.Title",
      icon: "fa-solid fa-dice"
    },
    form: {
      handler: RollConfigurationDialog.#handleFormSubmission
    },
    position: {
      width: 400
    },
    buildConfig: null,
    rendering: {
      dice: {
        max: 5,
        denominations: new Set(["d4", "d6", "d8", "d10", "d12", "d20"])
      }
    }
  };

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

  /** @override */
  static PARTS = {
    formulas: {
      template: "systems/dnd5e/templates/dice/roll-formulas.hbs"
    },
    configuration: {
      template: "systems/dnd5e/templates/dice/roll-configuration.hbs"
    },
    buttons: {
      template: "systems/dnd5e/templates/dice/roll-buttons.hbs"
    }
  };

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

  /**
   * Roll type to use when constructing the rolls.
   * @type {typeof BasicRoll}
   */
  static get rollType() {
    return CONFIG.Dice.BasicRoll;
  }

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

  /**
   * Roll configuration.
   * @type {BasicRollProcessConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

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

  /**
   * Configuration information for the roll message.
   * @type {BasicRollMessageConfiguration}
   */
  #message;

  get message() {
    return this.#message;
  }

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

  /**
   * The rolls being configured.
   * @type {BasicRoll[]}
   */
  #rolls;

  get rolls() {
    return this.#rolls;
  }

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

  /**
   * Roll type to use when constructing the rolls.
   * @type {typeof BasicRoll}
   */
  get rollType() {
    return this.options.rollType ?? this.constructor.rollType;
  }

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

  /**
   * Identify DiceTerms in this app's rolls.
   * @returns {{ icon: string, label: string }[]}
   * @protected
   */
  _identifyDiceTerms() {
    let dice = [];
    let shouldDisplay = true;

    /**
     * Determine if a given term is displayable.
     * @param {RollTerm} term  The term.
     * @returns {boolean|void}
     */
    const identifyTerm = term => {
      if ( !(term instanceof DiceTerm$2) ) return;
      // If any of the terms have complex components, do not attempt to display only some dice, bail out entirely.
      if ( !Number.isFinite(term.number) || !Number.isFinite(term.faces) ) return shouldDisplay = false;
      // If any of the terms are of an unsupported denomination, do not attempt to display only some dice, bail out
      // entirely.
      if ( !this.options.rendering.dice.denominations.has(term.denomination) ) return shouldDisplay = false;
      for ( let i = 0; i < term.number; i++ ) dice.push({
        icon: `systems/dnd5e/icons/svg/dice/${term.denomination}.svg`,
        label: term.denomination,
        denomination: term.denomination
      });
    };

    /**
     * Identify any DiceTerms in the given terms.
     * @param {RollTerm[]} terms  The terms.
     */
    const identifyDice = (terms=[]) => {
      for ( const term of terms ) {
        identifyTerm(term);
        if ( "dice" in term ) identifyDice(term.dice);
      }
    };

    this.rolls.forEach(roll => identifyDice(roll.terms));
    if ( dice.length > this.options.rendering.dice.max ) {
      // Compact dice display.
      const byDenom = dice.reduce((obj, { icon, denomination }) => {
        obj[denomination] ??= { icon, count: 0 };
        obj[denomination].count++;
        return obj;
      }, {});
      dice = Object.entries(byDenom).map(([d, { icon, count }]) => ({ icon, label: `${count}${d}` }));
      if ( dice.length > this.options.rendering.dice.max ) shouldDisplay = false;
    }
    else if ( !dice.length ) shouldDisplay = false;
    return shouldDisplay ? dice : [];
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "buttons":
        return this._prepareButtonsContext(context, options);
      case "configuration":
        return this._prepareConfigurationContext(context, options);
      case "formulas":
        return this._prepareFormulasContext(context, options);
      default:
        return context;
    }
  }

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

  /**
   * Prepare the context for the buttons.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareButtonsContext(context, options) {
    context.buttons = {
      roll: {
        default: true,
        icon: '<i class="fa-solid fa-dice" inert></i>',
        label: game.i18n.localize("DND5E.Roll")
      }
    };
    return context;
  }

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

  /**
   * Prepare the context for the roll configuration section.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareConfigurationContext(context, options) {
    context.fields = [{
      field: new foundry.data.fields.StringField({ label: game.i18n.localize("DND5E.RollMode") }),
      name: "rollMode",
      value: this.message.rollMode ?? this.options.default?.rollMode ?? game.settings.get("core", "rollMode"),
      options: Object.entries(CONFIG.Dice.rollModes)
        .map(([value, l]) => ({ value, label: game.i18n.localize(`${game.release.generation < 13 ? l : l.label}`) }))
    }];
    return context;
  }

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

  /**
   * Prepare the context for the formulas list.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareFormulasContext(context, options) {
    context.rolls = this.rolls.map(roll => ({ roll }));
    context.dice = this._identifyDiceTerms() || [];
    return context;
  }

  /* -------------------------------------------- */
  /*  Roll Handling                               */
  /* -------------------------------------------- */

  /**
   * Build a roll from the provided configuration objects.
   * @param {BasicRollProcessConfiguration} config  Roll configuration data.
   * @param {FormDataExtended} [formData]           Any data entered into the rolling prompt.
   */
  #buildRolls(config, formData) {
    const RollType = this.rollType;
    this.#rolls = config.rolls?.map((config, index) =>
      RollType.fromConfig(this._buildConfig(config, formData, index), this.config)
    ) ?? [];
  }

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

  /**
   * Prepare individual configuration object before building a roll.
   * @param {BasicRollConfiguration} config  Roll configuration data.
   * @param {FormDataExtended} [formData]    Any data entered into the rolling prompt.
   * @param {number} index                   Index of the roll within all rolls being prepared.
   * @returns {BasicRollConfiguration}
   * @protected
   */
  _buildConfig(config, formData, index) {
    config = foundry.utils.mergeObject({ parts: [], data: {}, options: {} }, config);

    /**
     * A hook event that fires when a roll config is built using the roll prompt.
     * @function dnd5e.buildRollConfig
     * @memberof hookEvents
     * @param {RollConfigurationDialog} app    Roll configuration dialog.
     * @param {BasicRollConfiguration} config  Roll configuration data.
     * @param {FormDataExtended} [formData]    Any data entered into the rolling prompt.
     * @param {number} index                   Index of the roll within all rolls being prepared.
     */
    Hooks.callAll("dnd5e.buildRollConfig", this, config, formData, index);

    const situational = formData?.get(`roll.${index}.situational`);
    if ( situational && (config.situational !== false) ) {
      config.parts.push("@situational");
      config.data.situational = situational;
    } else {
      config.parts.findSplice(v => v === "@situational");
    }

    this.options.buildConfig?.(this.config, config, formData, index);

    return config;
  }

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

  /**
   * Make any final modifications to rolls based on the button clicked.
   * @param {string} action  Action on the button clicked.
   * @returns {BasicRoll[]}
   * @protected
   */
  _finalizeRolls(action) {
    return this.rolls;
  }

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

  /**
   * Rebuild rolls based on an updated config and re-render the dialog.
   */
  rebuild() {
    this._onChangeForm(this.options.form, new Event("change"));
  }

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

  /**
   * Handle submission of the dialog using the form buttons.
   * @this {RollConfigurationDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    if ( formData.has("rollMode") ) this.message.rollMode = formData.get("rollMode");
    this.#rolls = this._finalizeRolls(event.submitter?.dataset?.action);
    await this.close({ dnd5e: { submitted: true } });
  }

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

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);

    const formData = new FormDataExtended(this.form);
    if ( formData.has("rollMode") ) this.message.rollMode = formData.get("rollMode");
    this.#buildRolls(foundry.utils.deepClone(this.#config), formData);
    this.render({ parts: ["formulas"] });
  }

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

  /** @override */
  _onClose(options={}) {
    if ( !options.dnd5e?.submitted ) this.#rolls = [];
  }

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

  /**
   * A helper to handle displaying and responding to the dialog.
   * @param {BasicRollProcessConfiguration} [config]   Initial roll configuration.
   * @param {BasicRollDialogConfiguration} [dialog]    Dialog configuration options.
   * @param {BasicRollMessageConfiguration} [message]  Message configuration.
   * @returns {Promise<BasicRoll[]>}
   */
  static async configure(config={}, dialog={}, message={}) {
    return new Promise(resolve => {
      const app = new this(config, message, dialog.options);
      app.addEventListener("close", () => resolve(app.rolls), { once: true });
      app.render({ force: true });
    });
  }
}

/**
 * Dialog for configuring d20 rolls.
 *
 * @param {D20RollProcessConfiguration} [config={}]           Initial roll configuration.
 * @param {BasicRollMessageConfiguration} [message={}]        Message configuration.
 * @param {BasicRollConfigurationDialogOptions} [options={}]  Dialog rendering options.
 */
class D20RollConfigurationDialog extends RollConfigurationDialog {

  /** @override */
  static get rollType() {
    return CONFIG.Dice.D20Roll;
  }

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

  /** @override */
  async _prepareButtonsContext(context, options) {
    let defaultButton = this.options.defaultButton;
    if ( !defaultButton ) {
      let advantage = false;
      let disadvantage = false;
      for ( const roll of this.config.rolls ) {
        if ( !roll.options ) continue;
        if ( roll.options.advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE ) advantage = true;
        else if ( roll.options.advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE ) disadvantage = true;
        else if ( roll.options.advantage && !roll.options.disadvantage ) advantage = true;
        else if ( !roll.options.advantage && roll.options.disadvantage ) disadvantage = true;
      }
      if ( advantage && !disadvantage ) defaultButton = "advantage";
      else if ( !advantage && disadvantage ) defaultButton = "disadvantage";
    }
    context.buttons = {
      advantage: {
        default: defaultButton === "advantage",
        label: game.i18n.localize("DND5E.Advantage")
      },
      normal: {
        default: !["advantage", "disadvantage"].includes(defaultButton),
        label: game.i18n.localize("DND5E.Normal")
      },
      disadvantage: {
        default: defaultButton === "disadvantage",
        label: game.i18n.localize("DND5E.Disadvantage")
      }
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Roll Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _finalizeRolls(action) {
    let advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.NORMAL;
    if ( action === "advantage" ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE;
    else if ( action === "disadvantage" ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE;
    return this.rolls.map(roll => {
      roll.options.advantageMode = advantageMode;
      roll.configureModifiers();
      return roll;
    });
  }
}

/**
 * @typedef {BasicRollConfigurationDialogOptions} AttackRollConfigurationDialogOptions
 * @property {FormSelectOption[]} ammunitionOptions  Ammunition that can be used with the attack.
 * @property {FormSelectOption[]} attackModeOptions  Different modes of attack.
 * @property {FormSelectOption[]} masteryOptions     Available masteries for the attacking weapon.
 */

/**
 * Extended roll configuration dialog that allows selecting attack mode, ammunition, and weapon mastery.
 */
class AttackRollConfigurationDialog extends D20RollConfigurationDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    ammunitionOptions: [],
    attackModeOptions: [],
    masteryOptions: []
  };

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

  /** @inheritDoc */
  async _prepareConfigurationContext(context, options) {
    context = await super._prepareConfigurationContext(context, options);
    const optionsFields = [
      { key: "attackMode", label: "DND5E.ATTACK.Mode.Label", options: this.options.attackModeOptions },
      { key: "ammunition", label: "DND5E.CONSUMABLE.Type.Ammunition.Label", options: this.options.ammunitionOptions },
      { key: "mastery", label: "DND5E.WEAPON.Mastery.Label", options: this.options.masteryOptions }
    ];
    context.fields = [
      ...optionsFields.map(({ key, label, options }) => options.length ? {
        field: new foundry.data.fields.StringField({ label: game.i18n.localize(label) }),
        name: key,
        options,
        value: this.config[key]
      } : null).filter(_ => _),
      ...context.fields
    ];
    return context;
  }
}

/**
 * Lightweight class containing scaling information for an item that is used in roll data to ensure it is available
 * in the correct format in roll formulas: `@scaling` is the scaling value, and `@scaling.increase` as the scaling
 * steps above baseline.
 *
 * @param {number} increase  Scaling steps above baseline.
 */
class Scaling {
  constructor(increase) {
    this.#increase = increase;
  }

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

  /**
   * Scaling steps above baseline.
   * @type {number}
   */
  #increase;

  get increase() {
    return this.#increase;
  }

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

  /**
   * Value of the scaling starting 1.
   * @type {string}
   */
  get value() {
    return this.#increase + 1;
  }

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

  /** @override */
  toString() {
    return this.value;
  }
}

const { BooleanField: BooleanField$H, EmbeddedDataField: EmbeddedDataField$5, NumberField: NumberField$J, SchemaField: SchemaField$V, SetField: SetField$w, StringField: StringField$1a } = foundry.data.fields;

/**
 * Field for storing damage data.
 */
class DamageField extends EmbeddedDataField$5 {
  constructor(options) {
    super(DamageData, options);
  }
}

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

/**
 * Data model that stores information on a single damage part.
 *
 * @property {number} number           Number of dice to roll.
 * @property {number} denomination     Die denomination to roll.
 * @property {string} bonus            Bonus added to the damage.
 * @property {Set<string>} types       One or more damage types. If multiple are selected, then the user will be able to
 *                                     select from those types.
 * @property {object} custom
 * @property {boolean} custom.enabled  Should the custom formula be used?
 * @property {string} custom.formula   Custom damage formula.
 * @property {object} scaling
 * @property {string} scaling.mode     How the damage scales in relation with levels.
 * @property {number} scaling.number   Number of dice to add per scaling level.
 * @property {string} scaling.formula  Arbitrary scaling formula which will be multiplied by scaling increase.
 */
class DamageData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      number: new NumberField$J({ min: 0, integer: true }),
      denomination: new NumberField$J({ min: 0, integer: true }),
      bonus: new FormulaField(),
      types: new SetField$w(new StringField$1a()),
      custom: new SchemaField$V({
        enabled: new BooleanField$H(),
        formula: new FormulaField()
      }),
      scaling: new SchemaField$V({
        mode: new StringField$1a(),
        number: new NumberField$J({ initial: 1, min: 0, integer: true }),
        formula: new FormulaField()
      })
    };
  }

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

  /**
   * The default damage formula.
   * @type {string}
   */
  get formula() {
    if ( this.custom.enabled ) return this.custom.formula ?? "";
    return this._automaticFormula();
  }

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

  /**
   * Produce the auto-generated formula from the `number`, `denomination`, and `bonus`.
   * @param {number} [increase=0]  Amount to increase the die count.
   * @returns {string}
   * @protected
   */
  _automaticFormula(increase=0) {
    let formula;
    const number = (this.number ?? 0) + increase;
    if ( number && this.denomination ) formula = `${number}d${this.denomination}`;
    if ( this.bonus ) formula = formula ? `${formula} + ${this.bonus}` : this.bonus;
    return formula ?? "";
  }

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

  /**
   * Scale the damage by a number of steps using its configured scaling configuration.
   * @param {number|Scaling} increase  Number of steps above base damage to scaling.
   * @returns {string}
   */
  scaledFormula(increase) {
    if ( increase instanceof Scaling ) increase = increase.increase;

    switch ( this.scaling.mode ) {
      case "whole": break;
      case "half": increase = Math.floor(increase * .5); break;
      default: increase = 0; break;
    }
    if ( !increase ) return this.formula;
    let formula;

    // If dice count scaling, increase the count on the first die rolled
    const dieIncrease = (this.scaling.number ?? 0) * increase;
    if ( this.custom.enabled ) {
      formula = this.custom.formula;
      formula = formula.replace(/^(\d)+d/, (match, number) => `${Number(number) + dieIncrease}d`);
    } else {
      formula = this._automaticFormula(dieIncrease);
    }

    // If custom scaling included, modify to match increase and append for formula
    if ( this.scaling.formula ) {
      let roll = new Roll(this.scaling.formula);
      roll = roll.alter(increase, 0, { multiplyNumeric: true });
      formula = formula ? `${formula} + ${roll.formula}` : roll.formula;
    }

    return formula;
  }

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

  /**
   * Step the die denomination up or down by a number of steps, sticking to proper die sizes. Will return `null` if
   * stepping reduced the denomination below minimum die size.
   * @param {number} [steps=1]  Number of steps to increase or decrease the denomination.
   * @returns {number|null}
   */
  steppedDenomination(steps=1) {
    return CONFIG.DND5E.dieSteps[Math.min(
      CONFIG.DND5E.dieSteps.indexOf(this.denomination) + steps,
      CONFIG.DND5E.dieSteps.length - 1
    )] ?? null;
  }
}

const { NumberField: NumberField$I, SchemaField: SchemaField$U, StringField: StringField$19 } = foundry.data.fields;

/**
 * Field for storing activation data.
 *
 * @property {string} type            Activation type (e.g. action, legendary action, minutes).
 * @property {number} value           Scalar value associated with the activation.
 * @property {string} condition       Condition required to activate this activity.
 */
class ActivationField extends SchemaField$U {
  constructor(fields={}, options={}) {
    fields = {
      type: new StringField$19({ initial: "action" }),
      value: new NumberField$I({ min: 0, integer: true }),
      condition: new StringField$19(),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.activation.scalar = CONFIG.DND5E.activityActivationTypes[this.activation.type]?.scalar ?? false;
    if ( !this.activation.scalar ) this.activation.value = null;

    if ( labels && this.activation.type ) {
      labels.activation = [
        this.activation.value, CONFIG.DND5E.activityActivationTypes[this.activation.type]?.label
      ].filterJoin(" ");
    }
  }
}

const { SchemaField: SchemaField$T, StringField: StringField$18 } = foundry.data.fields;

/**
 * Field for storing duration data.
 *
 * @property {string} value             Scalar value for the activity's duration.
 * @property {string} units             Units that are used for the duration.
 * @property {string} special           Description of any special duration details.
 */
class DurationField extends SchemaField$T {
  constructor(fields={}, options={}) {
    fields = {
      value: new FormulaField({ deterministic: true }),
      units: new StringField$18({ initial: "inst" }),
      special: new StringField$18(),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.duration.scalar = this.duration.units in CONFIG.DND5E.scalarTimePeriods;
    if ( this.duration.scalar ) {
      prepareFormulaValue(this, "duration.value", "DND5E.DURATION.FIELDS.duration.value.label", rollData);
    } else this.duration.value = null;

    if ( labels && this.duration.units ) {
      let duration = CONFIG.DND5E.timePeriods[this.duration.units] ?? "";
      if ( this.duration.value ) duration = `${this.duration.value} ${duration.toLowerCase()}`;
      labels.duration = duration;
      // TODO: Allow activities to indicate they require concentration regardless of the base item
      labels.concentrationDuration = this.properties?.has("concentration")
        ? game.i18n.format("DND5E.ConcentrationDuration", { duration }) : duration;
    }

    Object.defineProperty(this.duration, "getEffectData", {
      value: DurationField.getEffectDuration.bind(this.duration),
      configurable: true
    });
  }

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

  /**
   * Create duration data usable for an active effect based on this duration.
   * @this {DurationData}
   * @returns {EffectDurationData}
   */
  static getEffectDuration() {
    if ( !Number.isNumeric(this.value) ) return {};
    switch ( this.units ) {
      case "turn": return { turns: this.value };
      case "round": return { rounds: this.value };
      case "minute": return { seconds: this.value * 60 };
      case "hour": return { seconds: this.value * 60 * 60 };
      case "day": return { seconds: this.value * 60 * 60 * 24 };
      case "year": return { seconds: this.value * 60 * 60 * 24 * 365 };
      default: return {};
    }
  }
}

const { SchemaField: SchemaField$S, StringField: StringField$17 } = foundry.data.fields;

/**
 * Field for storing range data.
 *
 * @property {string} value                Scalar value for the activity's range.
 * @property {string} units                Units that are used for the range.
 * @property {string} special              Description of any special range details.
 */
class RangeField extends SchemaField$S {
  constructor(fields={}, options={}) {
    fields = {
      value: new FormulaField({ deterministic: true }),
      units: new StringField$17(),
      special: new StringField$17(),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.range.scalar = this.range.units in CONFIG.DND5E.movementUnits;
    if ( this.range.scalar ) {
      prepareFormulaValue(this, "range.value", "DND5E.RANGE.FIELDS.range.value.label", rollData);
    } else this.range.value = null;

    if ( labels && this.range.units ) {
      if ( this.range.scalar && this.range.value ) {
        labels.range = formatLength(this.range.value, this.range.units);
        labels.rangeParts = formatLength(this.range.value, this.range.units, { parts: true });
      } else if ( !this.range.scalar ) {
        labels.range = CONFIG.DND5E.distanceUnits[this.range.units];
      }
    } else if ( labels ) labels.range = game.i18n.localize("DND5E.DistSelf");
  }
}

const { BooleanField: BooleanField$G, SchemaField: SchemaField$R, StringField: StringField$16 } = foundry.data.fields;

/**
 * @typedef {object} TargetData
 * @property {object} template
 * @property {string} template.count        Number of templates created.
 * @property {boolean} template.contiguous  Must all created areas be connected to one another?
 * @property {string} template.type         Type of area of effect caused by this activity.
 * @property {string} template.size         Size of the activity's area of effect on its primary axis.
 * @property {string} template.width        Width of line area of effect.
 * @property {string} template.height       Height of cylinder area of effect.
 * @property {string} template.units        Units used to measure the area of effect sizes.
 * @property {object} affects
 * @property {string} affects.count         Number of individual targets that can be affected.
 * @property {string} affects.type          Type of targets that can be affected (e.g. creatures, objects, spaces).
 * @property {boolean} affects.choice       When targeting an area, can the user choose who it affects?
 * @property {string} affects.special       Description of special targeting.
 */

/**
 * Field for storing target data.
 */
class TargetField extends SchemaField$R {
  constructor(fields={}, options={}) {
    fields = {
      template: new SchemaField$R({
        count: new FormulaField({ deterministic: true }),
        contiguous: new BooleanField$G(),
        type: new StringField$16(),
        size: new FormulaField({ deterministic: true }),
        width: new FormulaField({ deterministic: true }),
        height: new FormulaField({ deterministic: true }),
        units: new StringField$16({ initial: () => defaultUnits("length") })
      }),
      affects: new SchemaField$R({
        count: new FormulaField({ deterministic: true }),
        type: new StringField$16(),
        choice: new BooleanField$G(),
        special: new StringField$16()
      }),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    this.target.affects.scalar = this.target.affects.type
      && (CONFIG.DND5E.individualTargetTypes[this.target.affects.type]?.scalar !== false);
    if ( this.target.affects.scalar ) {
      prepareFormulaValue(this, "target.affects.count", "DND5E.TARGET.FIELDS.target.affects.count.label", rollData);
    } else this.target.affects.count = null;

    this.target.template.dimensions = TargetField.templateDimensions(this.target.template.type);

    if ( this.target.template.type ) {
      this.target.template.count ||= "1";
      if ( this.target.template.dimensions.width ) this.target.template.width ||= "5";
      if ( this.target.template.dimensions.height ) this.target.template.height ||= "5";
      prepareFormulaValue(this, "target.template.count", "DND5E.TARGET.FIELDS.target.template.count.label", rollData);
      prepareFormulaValue(this, "target.template.size", "DND5E.TARGET.FIELDS.target.template.size.label", rollData);
      prepareFormulaValue(this, "target.template.width", "DND5E.TARGET.FIELDS.target.template.width.label", rollData);
      prepareFormulaValue(this, "target.template.height", "DND5E.TARGET.FIELDS.target.template.height.label", rollData);
    } else {
      this.target.template.count = null;
      this.target.template.size = null;
      this.target.template.width = null;
      this.target.template.height = null;
    }

    const pr = getPluralRules();

    // Generate the template label
    const templateConfig = CONFIG.DND5E.areaTargetTypes[this.target.template.type];
    if ( templateConfig ) {
      const parts = [];
      if ( this.target.template.count > 1 ) parts.push(`${this.target.template.count} ×`);
      if ( this.target.template.units in CONFIG.DND5E.movementUnits ) {
        parts.push(formatLength(this.target.template.size, this.target.template.units));
      }
      this.target.template.label = game.i18n.format(
        `${templateConfig.counted}.${pr.select(this.target.template.count || 1)}`, { number: parts.filterJoin(" ") }
      ).trim().capitalize();
    } else this.target.template.label = "";

    // Generate the affects label
    const affectsConfig = CONFIG.DND5E.individualTargetTypes[this.target.affects.type];
    this.target.affects.labels = {
      sheet: affectsConfig?.counted ? game.i18n.format(
        `${affectsConfig.counted}.${this.target.affects.count ? pr.select(this.target.affects.count) : "other"}`, {
          number: this.target.affects.count ? formatNumber(this.target.affects.count)
            : game.i18n.localize(`DND5E.TARGET.Count.${this.target.template.type ? "Every" : "Any"}`)
        }
      ).trim().capitalize() : (affectsConfig?.label ?? ""),
      statblock: game.i18n.format(
        `${affectsConfig?.counted ?? "DND5E.TARGET.Type.Target.Counted"}.${pr.select(this.target.affects.count || 1)}`,
        { number: formatNumber(this.target.affects.count || 1, { words: true }) }
      )
    };

    if ( labels ) labels.target = this.target.template.label || this.target.affects.labels.sheet;
  }

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

  /**
   * Create the template dimensions labels for a template type.
   * @param {string} type  Area of effect type.
   * @returns {{ size: string, [width]: string, [height]: string }}
   */
  static templateDimensions(type) {
    const sizes = CONFIG.DND5E.areaTargetTypes[type]?.sizes;
    const dimensions = { size: "DND5E.AreaOfEffect.Size.Label" };
    if ( sizes ) {
      dimensions.width = sizes.includes("width") && (sizes.includes("length") || sizes.includes("radius"));
      dimensions.height = sizes.includes("height");
      if ( sizes.includes("radius") ) dimensions.size = "DND5E.AreaOfEffect.Size.Radius";
      else if ( sizes.includes("length") ) dimensions.size = "DND5E.AreaOfEffect.Size.Length";
      else if ( sizes.includes("width") ) dimensions.size = "DND5E.AreaOfEffect.Size.Width";
      if ( sizes.includes("thickness") ) dimensions.width = "DND5E.AreaOfEffect.Size.Thickness";
      else if ( dimensions.width ) dimensions.width = "DND5E.AreaOfEffect.Size.Width";
      if ( dimensions.height ) dimensions.height = "DND5E.AreaOfEffect.Size.Height";
    }
    return dimensions;
  }
}

const { ArrayField: ArrayField$o, NumberField: NumberField$H, SchemaField: SchemaField$Q, StringField: StringField$15 } = foundry.data.fields;

/**
 * @import {
 *   BasicRollProcessConfiguration, BasicRollDialogConfiguration, BasicRollMessageConfiguration
 * } from "../../dice/basic-roll.mjs";
 */

/**
 * @typedef {object} UsesData
 * @property {number} spent                 Number of uses that have been spent.
 * @property {string} max                   Formula for the maximum number of uses.
 * @property {UsesRecoveryData[]} recovery  Recovery profiles for this activity's uses.
 */

/**
 * Data for a recovery profile for an activity's uses.
 *
 * @typedef {object} UsesRecoveryData
 * @property {string} period   Period at which this profile is activated.
 * @property {string} type     Whether uses are reset to full, reset to zero, or recover a certain number of uses.
 * @property {string} formula  Formula used to determine recovery if type is not reset.
 */

/**
 * Field for storing uses data.
 */
class UsesField extends SchemaField$Q {
  constructor(fields={}, options={}) {
    fields = {
      spent: new NumberField$H({ initial: 0, min: 0, integer: true }),
      max: new FormulaField({ deterministic: true }),
      recovery: new ArrayField$o(
        new SchemaField$Q({
          period: new StringField$15({ initial: "lr" }),
          type: new StringField$15({ initial: "recoverAll" }),
          formula: new FormulaField()
        })
      ),
      ...fields
    };
    super(fields, options);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel|BaseActivityData}
   * @param {object} rollData  Roll data used for formula replacements.
   * @param {object} [labels]  Object in which to insert generated labels.
   */
  static prepareData(rollData, labels) {
    prepareFormulaValue(this, "uses.max", "DND5E.USES.FIELDS.uses.max.label", rollData);
    this.uses.value = this.uses.max ? Math.clamp(this.uses.max - this.uses.spent, 0, this.uses.max) : 0;

    const periods = [];
    for ( const recovery of this.uses.recovery ) {
      if ( recovery.period === "recharge" ) {
        recovery.formula ??= "6";
        recovery.type = "recoverAll";
        recovery.recharge = {
          options: Array.fromRange(5, 2).reverse().map(min => ({
            value: min,
            label: game.i18n.format("DND5E.USES.Recovery.Recharge.Range", {
              range: min === 6 ? formatNumber(6) : formatRange(min, 6)
            })
          }))
        };
        if ( labels ) labels.recharge ??= `${game.i18n.localize("DND5E.Recharge")} [${
          recovery.formula}${parseInt(recovery.formula) < 6 ? "+" : ""}]`;
      } else if ( recovery.period in CONFIG.DND5E.limitedUsePeriods ) {
        const config = CONFIG.DND5E.limitedUsePeriods[recovery.period];
        periods.push(config.abbreviation ?? config.label);
      }
    }
    if ( labels ) labels.recovery = game.i18n.getListFormatter({ style: "narrow" }).format(periods);

    this.uses.label = UsesField.getStatblockLabel.call(this);

    Object.defineProperty(this.uses, "rollRecharge", {
      value: UsesField.rollRecharge.bind(this.parent?.system ? this.parent : this),
      configurable: true
    });
  }

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

  /**
   * Create a label for uses data that matches the style seen on NPC stat blocks. Complex recovery data might result
   * in no label being generated if it doesn't represent recovery that can be normally found on a NPC.
   * @this {ItemDataModel|BaseActivityData}
   * @returns {string}
   */
  static getStatblockLabel() {
    if ( !this.uses.max || (this.uses.recovery.length !== 1) ) return "";
    const recovery = this.uses.recovery[0];

    // Recharge X–Y
    if ( recovery.period === "recharge" ) {
      const value = parseInt(recovery.formula);
      return `${game.i18n.localize("DND5E.Recharge")} ${value === 6 ? "6" : `${value}–6`}`;
    }

    // Recharge after a Short or Long Rest
    if ( ["lr", "sr"].includes(recovery.period) && (this.uses.max === 1) ) {
      return game.i18n.localize(`DND5E.Recharge${recovery.period === "sr" ? "Short" : "Long"}`);
    }

    // X/Day
    const period = CONFIG.DND5E.limitedUsePeriods[recovery.period === "sr" ? "sr" : "day"]?.label ?? "";
    if ( !period ) return "";
    return `${this.uses.max}/${period}`;
  }

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

  /**
   * Determine uses recovery.
   * @this {ItemDataModel|BaseActivityData}
   * @param {string[]} periods  Recovery periods to check.
   * @param {object} rollData   Roll data to use when evaluating recover formulas.
   * @returns {Promise<{ updates: object, rolls: BasicRoll[] }|false>}
   */
  static async recoverUses(periods, rollData) {
    if ( !this.uses?.recovery.length ) return false;

    // Search the recovery profiles in order to find the first matching period,
    // and then find the first profile that uses that recovery period
    let profile;
    findPeriod: {
      for ( const period of periods ) {
        for ( const recovery of this.uses.recovery ) {
          if ( recovery.period === period ) {
            profile = recovery;
            break findPeriod;
          }
        }
      }
    }
    if ( !profile ) return false;

    const updates = {};
    const rolls = [];
    const item = this.item ?? this.parent;

    if ( profile.type === "recoverAll" ) updates.spent = 0;
    else if ( profile.type === "loseAll" ) updates.spent = this.uses.max;
    else if ( profile.formula ) {
      let roll;
      let total;
      try {
        const delta = this.parent instanceof Item ? { item: this.parent.id, keyPath: "system.uses.spent" }
          : { item: this.item.id, keyPath: `system.activities.${this.id}.uses.spent` };
        roll = new CONFIG.Dice.BasicRoll(profile.formula, rollData, { delta });
        if ( ["day", "dawn", "dusk"].includes(profile.period)
          && (game.settings.get("dnd5e", "restVariant") === "gritty") ) {
          roll.alter(7, 0, { multiplyNumeric: true });
        }
        total = (await roll.evaluate()).total;
      } catch(err) {
        Hooks.onError("UsesField#recoverUses", err, {
          msg: game.i18n.format("DND5E.ItemRecoveryFormulaWarning", {
            name: item.name, formula: profile.formula, uuid: this.uuid ?? item.uuid
          }),
          log: "error",
          notify: "error"
        });
        return false;
      }

      const newSpent = Math.clamp(this.uses.spent - total, 0, this.uses.max);
      if ( newSpent !== this.uses.spent ) {
        updates.spent = newSpent;
        if ( !roll.isDeterministic ) rolls.push(roll);
      }
    }

    return { updates, rolls };
  }

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

  /**
   * @typedef {BasicRollProcessConfiguration} RechargeRollProcessConfiguration
   * @property {boolean} [apply]  Apply the uses updates back to the item or activity. If set to `false`, then the
   *                              `dnd5e.postRollRecharge` hook won't be called.
   */

  /**
   * Rolls a recharge test for an Item or Activity that uses the d6 recharge mechanic.
   * @this {Item5e|Activity}
   * @param {RechargeRollProcessConfiguration} config  Configuration information for the roll.
   * @param {BasicRollDialogConfiguration} dialog      Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} message    Configuration for the roll message.
   * @returns {Promise<BasicRoll[]|{ rolls: BasicRoll[], updates: object }|void>}  The created Roll instances, update
   *                                                                               data, or nothing if not rolled.
   */
  static async rollRecharge(config={}, dialog={}, message={}) {
    const uses = this.system ? this.system.uses : this.uses;
    const recharge = uses?.recovery.find(({ period }) => period === "recharge");
    if ( !recharge || !uses?.spent ) return;

    let oldReturn = false;
    if ( config.apply === undefined ) {
      foundry.utils.logCompatibilityWarning(
        "The `apply` parameter should be passed to `rollRecharge` to opt-in to the new return behavior.",
        { since: "DnD5e 4.3", until: "DnD5e 5.0" }
      );
      oldReturn = config.apply = true;
    }

    const rollConfig = foundry.utils.mergeObject({
      rolls: [{
        parts: ["1d6"],
        data: this.getRollData(),
        options: {
          delta: this instanceof Item ? { item: this.id, keyPath: "system.uses.spent" }
            : { item: this.item.id, keyPath: `system.activities.${this.id}.uses.spent` },
          target: parseInt(recharge.formula)
        }
      }]
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "recharge"];
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({ configure: false }, dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.actor.token })
      },
      rollMode: game.settings.get("core", "rollMode")
    }, message);

    if ( "dnd5e.preRollRecharge" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preRollRecharge` hook has been deprecated and replaced with `dnd5e.preRollRechargeV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      const hookData = {
        formula: rollConfig.rolls[0].parts[0], data: rollConfig.rolls[0].data,
        target: rollConfig.rolls[0].options.target, chatMessage: messageConfig.create
      };
      if ( Hooks.call("dnd5e.preRollRecharge", this, hookData) === false ) return;
      rollConfig.rolls[0].parts[0] = hookData.formula;
      rollConfig.rolls[0].data = hookData.data;
      rollConfig.rolls[0].options.target = hookData.target;
      messageConfig.create = hookData.chatMessage;
    }

    const rolls = await CONFIG.Dice.BasicRoll.buildConfigure(rollConfig, dialogConfig, messageConfig);
    await CONFIG.Dice.BasicRoll.buildEvaluate(rolls, rollConfig, messageConfig);
    if ( !rolls.length ) return;
    messageConfig.data.flavor = game.i18n.format("DND5E.ItemRechargeCheck", {
      name: this.name,
      result: game.i18n.localize(`DND5E.ItemRecharge${rolls[0].isSuccess ? "Success" : "Failure"}`)
    });
    await CONFIG.Dice.BasicRoll.buildPost(rolls, rollConfig, messageConfig);

    const updates = {};
    if ( rolls[0].isSuccess ) {
      if ( this instanceof Item ) updates["system.uses.spent"] = 0;
      else updates["uses.spent"] = 0;
    }

    /**
     * A hook event that fires after an Item or Activity has rolled to recharge, but before any usage changes have
     * been made.
     * @function dnd5e.rollRechargeV2
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls             The resulting rolls.
     * @param {object} data
     * @param {Item5e|Activity} data.subject  Item or Activity for which the roll was performed.
     * @param {object} data.updates           Updates to be applied to the subject.
     * @returns {boolean}                     Explicitly return `false` to prevent updates from being performed.
     */
    if ( Hooks.call("dnd5e.rollRechargeV2", rolls, { subject: this, updates }) === false ) return rolls;

    if ( "dnd5e.rollRecharge" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.rollRecharge` hook has been deprecated and replaced with `dnd5e.rollRechargeV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      if ( Hooks.call("dnd5e.rollRecharge", this, rolls[0]) === false ) return rolls;
    }

    if ( rollConfig.apply && !foundry.utils.isEmpty(updates) ) await this.update(updates);

    /**
     * A hook event that fires after an Item or Activity has rolled recharge and usage updates have been performed.
     * @function dnd5e.postRollRecharge
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls     The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.subject  Item or Activity for which the roll was performed.
     */
    Hooks.callAll("dnd5e.postRollRecharge", rolls, { subject: this });

    return oldReturn ? rolls : { rolls, updates };
  }
}

const { DocumentIdField: DocumentIdField$a, SchemaField: SchemaField$P } = foundry.data.fields;

/**
 * Field for storing an active effects applied by an activity.
 *
 * @property {string} _id  ID of the effect to apply.
 */
class AppliedEffectField extends SchemaField$P {
  constructor(fields={}, options={}) {
    super({
      _id: new DocumentIdField$a(),
      ...fields
    }, options);
  }

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

  /** @inheritDoc */
  initialize(value, model, options={}) {
    const obj = super.initialize(value, model, options);
    const item = model.item;

    Object.defineProperty(obj, "effect", {
      get() { return item?.effects.get(this._id); },
      configurable: true
    });

    return obj;
  }
}

const {
  ArrayField: ArrayField$n, BooleanField: BooleanField$F, DocumentIdField: DocumentIdField$9, FilePathField: FilePathField$2, IntegerSortField: IntegerSortField$2, SchemaField: SchemaField$O, StringField: StringField$14
} = foundry.data.fields;

/**
 * Data for effects that can be applied.
 *
 * @typedef {object} EffectApplicationData
 * @property {string} _id  ID of the effect to apply.
 */

/**
 * Data model for activities.
 *
 * @property {string} _id                        Unique ID for the activity on an item.
 * @property {string} type                       Type name of the activity used to build a specific activity class.
 * @property {string} name                       Name for this activity.
 * @property {string} img                        Image that represents this activity.
 * @property {ActivationField} activation        Activation time & conditions.
 * @property {boolean} activation.override       Override activation values inferred from item.
 * @property {object} consumption
 * @property {object} consumption.scaling
 * @property {boolean} consumption.scaling.allowed          Can this non-spell activity be activated at higher levels?
 * @property {string} consumption.scaling.max               Maximum number of scaling levels for this item.
 * @property {boolean} consumption.spellSlot                If this is on a spell, should it consume a spell slot?
 * @property {ConsumptionTargetData[]} consumption.targets  Collection of consumption targets.
 * @property {object} description
 * @property {string} description.chatFlavor     Extra text displayed in the activation chat message.
 * @property {DurationField} duration            Duration of the effect.
 * @property {boolean} duration.concentration    Does this effect require concentration?
 * @property {boolean} duration.override         Override duration values inferred from item.
 * @property {EffectApplicationData[]} effects   Linked effects that can be applied.
 * @property {object} range
 * @property {boolean} range.override            Override range values inferred from item.
 * @property {TargetData} target
 * @property {boolean} target.override           Override target values inferred from item.
 * @property {boolean} target.prompt             Should the player be prompted to place the template?
 * @property {UsesData} uses                     Uses available to this activity.
 */
class BaseActivityData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = [
    "DND5E.ACTIVITY", "DND5E.ACTIVATION", "DND5E.CONSUMPTION",
    "DND5E.DURATION", "DND5E.RANGE", "DND5E.TARGET", "DND5E.USES"
  ];

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

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField$9({ initial: () => foundry.utils.randomID() }),
      type: new StringField$14({
        blank: false, required: true, readOnly: true, initial: () => this.metadata.type
      }),
      name: new StringField$14({ initial: undefined }),
      img: new FilePathField$2({ initial: undefined, categories: ["IMAGE"], base64: false }),
      sort: new IntegerSortField$2(),
      activation: new ActivationField({
        override: new BooleanField$F()
      }),
      consumption: new SchemaField$O({
        scaling: new SchemaField$O({
          allowed: new BooleanField$F(),
          max: new FormulaField({ deterministic: true })
        }),
        spellSlot: new BooleanField$F({ initial: true }),
        targets: new ConsumptionTargetsField()
      }),
      description: new SchemaField$O({
        chatFlavor: new StringField$14()
      }),
      duration: new DurationField({
        concentration: new BooleanField$F(),
        override: new BooleanField$F()
      }),
      effects: new ArrayField$n(new AppliedEffectField()),
      range: new RangeField({
        override: new BooleanField$F()
      }),
      target: new TargetField({
        override: new BooleanField$F(),
        prompt: new BooleanField$F({ initial: true })
      }),
      uses: new UsesField()
    };
  }

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

  /**
   * The primary ability for this activity that will be available as `@mod` in roll data.
   * @type {string|null}
   */
  get ability() {
    return this.isSpell ? this.spellcastingAbility : null;
  }

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

  /**
   * Helper property to translate this activity type into the old `actionType`.
   * @type {string}
   */
  get actionType() {
    return this.metadata.data;
  }

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

  /**
   * A specific set of activation-specific labels displayed in chat cards.
   * @type {object|null}
   */
  get activationLabels() {
    if ( !this.activation.type || this.isSpell ) return null;
    const { activation, duration, range, reach, target } = this.labels;
    return { activation, duration, range, reach, target };
  }

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

  /**
   * Effects that can be applied from this activity.
   * @type {ActiveEffect5e[]|null}
   */
  get applicableEffects() {
    return this.effects?.map(e => e.effect).filter(e => e) ?? null;
  }

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

  /**
   * Can consumption scaling be configured?
   * @type {boolean}
   */
  get canConfigureScaling() {
    return this.consumption.scaling.allowed || (this.isSpell && (this.item.system.level > 0));
  }

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

  /**
   * Is scaling possible with this activity?
   * @type {boolean}
   */
  get canScale() {
    return this.consumption.scaling.allowed || (this.isSpell && this.item.system.level > 0
      && CONFIG.DND5E.spellPreparationModes[this.item.system.preparation.mode]?.upcast);
  }

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

  /**
   * Can this activity's damage be scaled?
   * @type {boolean}
   */
  get canScaleDamage() {
    return this.consumption.scaling.allowed || this.isScaledScroll || this.isSpell;
  }

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

  /**
   * Is this activity on a spell scroll that is scaled.
   * @type {boolean}
   */
  get isScaledScroll() {
    return !!this.item.getFlag("dnd5e", "spellLevel");
  }

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

  /**
   * Is this activity on a spell?
   * @type {boolean}
   */
  get isSpell() {
    return this.item.type === "spell";
  }

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

  /**
   * Does this activity or its item require concentration?
   * @type {boolean}
   */
  get requiresConcentration() {
    return this.duration.concentration;
  }

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

  /**
   * Does activating this activity consume a spell slot?
   * @type {boolean}
   */
  get requiresSpellSlot() {
    if ( !this.isSpell || !this.actor?.system.spells ) return false;
    return this.canScale;
  }

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

  /**
   * Retrieve the spellcasting ability that can be used with this activity.
   * @type {string|null}
   */
  get spellcastingAbility() {
    let ability;
    if ( this.isSpell ) ability = this.item.system.availableAbilities?.first();
    return ability ?? this.actor?.system.attributes?.spellcasting ?? null;
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /**
   * Static ID used for the automatically generated activity created during migration.
   * @type {string}
   */
  static INITIAL_ID = staticID("dnd5eactivity");

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

  /**
   * Migrate data from the item to a newly created activity.
   * @param {object} source              Item's candidate source data.
   * @param {object} [options={}]
   * @param {number} [options.offset=0]  Adjust the default ID using this number when creating multiple activities.
   */
  static createInitialActivity(source, { offset=0, ...options }={}) {
    const activityData = this.transformTypeData(source, {
      _id: this.INITIAL_ID.replace("0", offset),
      type: this.metadata.type,
      activation: this.transformActivationData(source, options),
      consumption: this.transformConsumptionData(source, options),
      description: this.transformDescriptionData(source, options),
      duration: this.transformDurationData(source, options),
      effects: this.transformEffectsData(source, options),
      range: this.transformRangeData(source, options),
      target: this.transformTargetData(source, options),
      uses: this.transformUsesData(source, options)
    }, options);
    foundry.utils.setProperty(source, `system.activities.${activityData._id}`, activityData);
    foundry.utils.setProperty(source, "flags.dnd5e.persistSourceMigration", true);
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's activation object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformActivationData(source, options) {
    if ( source.type === "spell" ) return {};
    return {
      type: source.system.activation?.type === "none" ? "" : (source.system.activation?.type ?? ""),
      value: source.system.activation?.cost ?? null,
      condition: source.system.activation?.condition ?? ""
    };
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's consumption object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformConsumptionData(source, options) {
    const targets = [];

    const type = {
      attribute: "attribute",
      hitDice: "hitDice",
      material: "material",
      charges: "itemUses"
    }[source.system.consume?.type];

    if ( type ) targets.push({
      type,
      target: source.system.consume?.target ?? "",
      value: source.system.consume?.amount ?? "1",
      scaling: {
        mode: source.system.consume?.scale ? "amount" : "",
        formula: ""
      }
    });

    // If no target type set but this item has max uses, set consumption type to itemUses with blank target
    else if ( source.system.uses?.max ) targets.push({
      type: "itemUses",
      target: "",
      value: "1",
      scaling: {
        mode: source.system.consume?.scale ? "amount" : "",
        formula: ""
      }
    });

    if ( source.system.recharge?.value && source.system.uses?.per ) targets.push({
      type: source.system.uses?.max ? "activityUses" : "itemUses",
      target: "",
      value: "1",
      scaling: { mode: "", formula: "" }
    });

    return {
      targets,
      scaling: {
        allowed: source.system.consume?.scale ?? false,
        max: ""
      }
    };
  }

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

  /**
   * Transform an old damage part into the new damage part format.
   * @param {object} source  Item's candidate source data to transform.
   * @param {string[]} part  The damage part to transform.
   * @returns {object}       Creation data for new activity.
   */
  static transformDamagePartData(source, [formula, type]) {
    const data = {
      number: null,
      denomination: null,
      bonus: "",
      types: type ? [type] : [],
      custom: {
        enabled: false,
        formula: ""
      },
      scaling: {
        mode: source?.system.scaling?.mode !== "none" ? "whole" : "",
        number: null,
        formula: source?.system.scaling?.formula ?? ""
      }
    };

    const parsed = (formula ?? "").match(/^\s*(\d+)d(\d+)(?:\s*([+|-])\s*(@?[\w\d.-]+))?\s*$/i);
    if ( parsed && CONFIG.DND5E.dieSteps.includes(Number(parsed[2])) ) {
      data.number = Number(parsed[1]);
      data.denomination = Number(parsed[2]);
      if ( parsed[4] ) data.bonus = parsed[3] === "-" ? `-${parsed[4]}` : parsed[4];
    } else if ( formula ) {
      data.custom.enabled = true;
      data.custom.formula = formula;
    }

    // If scaling denomination matches the damage denomination, set scaling using number rather than formula
    const scaling = data.scaling.formula.match(/^\s*(\d+)d(\d+)\s*$/i);
    if ( (scaling && (Number(scaling[2]) === data.denomination)) || (source.system.scaling?.mode === "cantrip") ) {
      data.scaling.number = Number(scaling?.[1] || 1);
      data.scaling.formula = "";
    }

    return data;
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's description object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformDescriptionData(source, options) {
    return {
      chatFlavor: source.system.chatFlavor ?? ""
    };
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's duration object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformDurationData(source, options) {
    if ( source.type === "spell" ) return {};
    const concentration = !!source.system.properties?.findSplice(p => p === "concentration");
    return {
      concentration,
      value: source.system.duration?.value ?? null,
      units: source.system.duration?.units ?? "inst",
      special: ""
    };
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's effects array.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object[]}      Creation data for new activity.
   */
  static transformEffectsData(source, options) {
    return source.effects
      .filter(e => !e.transfer && (e.type !== "enchantment") && (e.flags?.dnd5e?.type !== "enchantment"))
      .map(e => ({ _id: e._id }));
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's range object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformRangeData(source, options) {
    if ( source.type === "spell" ) return {};
    return {
      value: source.system.range?.value ?? null,
      units: source.system.range?.units ?? "",
      special: ""
    };
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's target object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformTargetData(source, options) {
    if ( source.type === "spell" ) return {
      prompt: source.system.target?.prompt ?? true
    };

    const data = {
      template: {
        count: "",
        contiguous: false,
        type: "",
        size: "",
        width: "",
        height: "",
        units: source.system.target?.units ?? "ft"
      },
      affects: {
        count: "",
        type: "",
        choice: false,
        special: ""
      },
      prompt: source.system.target?.prompt ?? true
    };

    if ( source.system.target?.type in CONFIG.DND5E.areaTargetTypes ) foundry.utils.mergeObject(data, {
      template: {
        type: source.system.target?.type ?? "",
        size: source.system.target?.value ?? "",
        width: source.system.target?.width ?? ""
      }
    });

    else foundry.utils.mergeObject(data, {
      affects: {
        count: source.system.target?.value ?? "",
        type: source.system.target?.type ?? ""
      }
    });

    return data;
  }

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

  /**
   * Perform any type-specific data transformations.
   * @param {object} source        Item's candidate source data to transform.
   * @param {object} activityData  In progress creation data.
   * @param {object} options       Additional options passed to the creation process.
   * @returns {object}             Creation data for new activity.
   */
  static transformTypeData(source, activityData, options) {
    return activityData;
  }

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

  /**
   * Fetch data from the item source and transform it into an activity's uses object.
   * @param {object} source   Item's candidate source data to transform.
   * @param {object} options  Additional options passed to the creation process.
   * @returns {object}        Creation data for new activity.
   */
  static transformUsesData(source, options) {
    // Do not add a recharge recovery to the activity if the parent item would already get recharge recovery.
    if ( !source.system.recharge?.value || !source.system.uses?.max || !source.system.uses?.per ) {
      return { spent: 0, max: "", recovery: [] };
    }
    return {
      spent: source.system.recharge.charged ? 0 : 1,
      max: "1",
      recovery: [{
        period: "recharge",
        type: "recoverAll",
        formula: String(source.system.recharge.value)
      }]
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data related to this activity.
   */
  prepareData() {
    this.name = this.name || game.i18n.localize(this.metadata?.title);
    this.img = this.img || this.metadata?.img;
    this.labels ??= {};
    const addBaseIndices = data => data?.forEach((d, idx) => Object.defineProperty(d, "_index", { value: idx }));
    addBaseIndices(this.consumption?.targets);
    addBaseIndices(this.damage?.parts);
    addBaseIndices(this.effects);
    addBaseIndices(this.uses?.recovery);
  }

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

  /**
   * Perform final preparation after containing item is prepared.
   * @param {object} [rollData]  Deterministic roll data from the activity.
   */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });

    if ( this.activation ) this._setOverride("activation");
    if ( this.duration ) this._setOverride("duration");
    if ( this.range ) this._setOverride("range");
    if ( this.target ) this._setOverride("target");

    Object.defineProperty(this, "_inferredSource", {
      value: Object.freeze(this.toObject(false)),
      configurable: false,
      enumerable: false,
      writable: false
    });

    if ( this.activation ) ActivationField.prepareData.call(this, rollData, this.labels);
    if ( this.duration ) DurationField.prepareData.call(this, rollData, this.labels);
    if ( this.range ) RangeField.prepareData.call(this, rollData, this.labels);
    if ( this.target ) TargetField.prepareData.call(this, rollData, this.labels);
    if ( this.uses ) UsesField.prepareData.call(this, rollData, this.labels);

    const actor = this.item.actor;
    if ( !actor || !("consumption" in this) ) return;
    for ( const target of this.consumption.targets ) {
      if ( !["itemUses", "material"].includes(target.type) || !target.target ) continue;

      // Re-link UUIDs in consumption fields to explicit items on the actor
      if ( target.target.includes(".") ) {
        const item = actor.sourcedItems?.get(target.target, { legacy: false })?.first();
        if ( item ) target.target = item.id;
      }

      // If targeted item isn't found, display preparation warning
      if ( !actor.items.get(target.target) ) {
        const message = game.i18n.format("DND5E.CONSUMPTION.Warning.MissingItem", {
          activity: this.name, item: this.item.name
        });
        actor._preparationWarnings.push({ message, link: this.uuid, type: "warning" });
      }
    }
  }

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

  /**
   * Prepare the label for a compiled and simplified damage formula.
   * @param {DamageData[]} parts  Damage parts to create labels for.
   * @param {object} rollData     Deterministic roll data from the item.
   */
  prepareDamageLabel(parts, rollData) {
    this.labels.damage = parts.map((part, index) => {
      let formula;
      try {
        formula = part.formula;
        if ( part.base ) {
          if ( this.item.system.magicAvailable ) formula += ` + ${this.item.system.magicalBonus ?? 0}`;
          if ( (this.item.type === "weapon") && !/@mod\b/.test(formula) ) formula += " + @mod";
        }
        if ( !index && this.item.system.damageBonus ) formula += ` + ${this.item.system.damageBonus}`;
        const roll = new CONFIG.Dice.BasicRoll(formula, rollData);
        roll.simplify();
        formula = simplifyRollFormula(roll.formula, { preserveFlavor: true });
      } catch(err) {
        console.warn(`Unable to simplify formula for ${this.name} in item ${this.item.name}${
          this.actor ? ` on ${this.actor.name} (${this.actor.id})` : ""
        } (${this.uuid})`, err);
      }

      let label = formula;
      if ( part.types.size ) {
        label = `${formula} ${game.i18n.getListFormatter({ type: "conjunction" }).format(
          Array.from(part.types)
            .map(p => CONFIG.DND5E.damageTypes[p]?.label ?? CONFIG.DND5E.healingTypes[p]?.label)
            .filter(t => t)
        )}`;
      }

      return { formula, damageType: part.types.size === 1 ? part.types.first() : null, label, base: part.base };
    });
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Perform preliminary operations before an Activity is created.
   * @param {object} data     The initial data object provided to the document creation request.
   * @returns {boolean|void}  A return value of false indicates the creation operation should be cancelled.
   * @protected
   */
  _preCreate(data) {}

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

  /**
   * Retrieve the action type reflecting changes based on the provided attack mode.
   * @param {string} [attackMode=""]
   * @returns {string}
   */
  getActionType(attackMode="") {
    let actionType = this.actionType;
    if ( (actionType === "mwak") && (attackMode?.startsWith("thrown") || (attackMode === "ranged")) ) return "rwak";
    return actionType;
  }

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

  /**
   * Get the roll parts used to create the damage rolls.
   * @param {Partial<DamageRollProcessConfiguration>} [config={}]
   * @returns {DamageRollProcessConfiguration}
   */
  getDamageConfig(config={}) {
    if ( !this.damage?.parts ) return foundry.utils.mergeObject({ rolls: [] }, config);

    const rollConfig = foundry.utils.mergeObject({ scaling: 0 }, config);
    const rollData = this.getRollData();
    rollConfig.rolls = this.damage.parts
      .map((d, index) => this._processDamagePart(d, rollConfig, rollData, index))
      .filter(d => d.parts.length)
      .concat(config.rolls ?? []);

    return rollConfig;
  }

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

  /**
   * Process a single damage part into a roll configuration.
   * @param {DamageData} damage                                   Damage to prepare for the roll.
   * @param {Partial<DamageRollProcessConfiguration>} rollConfig  Roll configuration being built.
   * @param {object} rollData                                     Roll data to populate with damage data.
   * @param {number} [index=0]                                    Index of the damage part.
   * @returns {DamageRollConfiguration}
   * @protected
   */
  _processDamagePart(damage, rollConfig, rollData, index=0) {
    const scaledFormula = damage.scaledFormula(rollData.scaling);
    const parts = scaledFormula ? [scaledFormula] : [];
    const data = { ...rollData };

    if ( index === 0 ) {
      const actionType = this.getActionType(rollConfig.attackMode);
      const bonus = foundry.utils.getProperty(this.actor ?? {}, `system.bonuses.${actionType}.damage`);
      if ( bonus && !/^0+$/.test(bonus) ) parts.push(bonus);
      if ( this.item.system.damageBonus ) parts.push(String(this.item.system.damageBonus));
    }

    const lastType = this.item.getFlag("dnd5e", `last.${this.id}.damageType.${index}`);

    return {
      data, parts,
      options: {
        type: (damage.types.has(lastType) ? lastType : null) ?? damage.types.first(),
        types: Array.from(damage.types),
        properties: Array.from(this.item.system.properties ?? [])
          .filter(p => CONFIG.DND5E.itemProperties[p]?.isPhysical)
      }
    };
  }

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

  /**
   * Add an `canOverride` property to the provided object and, if `override` is `false`, replace the data on the
   * activity with data from the item.
   * @param {string} keyPath  Path of the property to set on the activity.
   * @internal
   */
  _setOverride(keyPath) {
    const obj = foundry.utils.getProperty(this, keyPath);
    Object.defineProperty(obj, "canOverride", {
      value: safePropertyExists(this.item.system, keyPath),
      configurable: true,
      enumerable: false
    });
    if ( obj.canOverride && !obj.override ) {
      foundry.utils.mergeObject(obj, foundry.utils.getProperty(this.item.system, keyPath));
    }
  }
}

const { ArrayField: ArrayField$m, BooleanField: BooleanField$E, NumberField: NumberField$G, SchemaField: SchemaField$N, StringField: StringField$13 } = foundry.data.fields;

/**
 * Data model for an attack activity.
 *
 * @property {object} attack
 * @property {string} attack.ability              Ability used to make the attack and determine damage.
 * @property {string} attack.bonus                Arbitrary bonus added to the attack.
 * @property {object} attack.critical
 * @property {number} attack.critical.threshold   Minimum value on the D20 needed to roll a critical hit.
 * @property {boolean} attack.flat                Should the bonus be used in place of proficiency & ability modifier?
 * @property {object} attack.type
 * @property {string} attack.type.value           Is this a melee or ranged attack?
 * @property {string} attack.type.classification  Is this a unarmed, weapon, or spell attack?
 * @property {object} damage
 * @property {object} damage.critical
 * @property {string} damage.critical.bonus       Extra damage applied when a critical is rolled. Added to the base
 *                                                damage or first damage part.
 * @property {boolean} damage.includeBase         Should damage defined by the item be included with other damage parts?
 * @property {DamageData[]} damage.parts          Parts of damage to inflict.
 */
class AttackActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      attack: new SchemaField$N({
        ability: new StringField$13(),
        bonus: new FormulaField(),
        critical: new SchemaField$N({
          threshold: new NumberField$G({ integer: true, positive: true })
        }),
        flat: new BooleanField$E(),
        type: new SchemaField$N({
          value: new StringField$13(),
          classification: new StringField$13()
        })
      }),
      damage: new SchemaField$N({
        critical: new SchemaField$N({
          bonus: new FormulaField()
        }),
        includeBase: new BooleanField$E({ initial: true }),
        parts: new ArrayField$m(new DamageField())
      })
    };
  }

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

  /** @override */
  get ability() {
    if ( this.attack.ability === "none" ) return null;
    if ( this.attack.ability === "spellcasting" ) return this.spellcastingAbility;
    if ( this.attack.ability in CONFIG.DND5E.abilities ) return this.attack.ability;

    const availableAbilities = this.availableAbilities;
    if ( !availableAbilities?.size ) return null;
    if ( availableAbilities?.size === 1 ) return availableAbilities.first();
    const abilities = this.actor?.system.abilities ?? {};
    return availableAbilities.reduce((largest, ability) =>
      (abilities[ability]?.mod ?? -Infinity) > (abilities[largest]?.mod ?? -Infinity) ? ability : largest
    , availableAbilities.first());
  }

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

  /** @override */
  get actionType() {
    const type = this.attack.type;
    return `${type.value === "ranged" ? "r" : "m"}${type.classification === "spell" ? "sak" : "wak"}`;
  }

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

  /** @inheritDoc */
  get activationLabels() {
    const labels = super.activationLabels;
    if ( labels && (this.item.type === "weapon") && !this.range.override ) {
      if ( this.item.labels?.range ) labels.range = this.item.labels.range;
      if ( this.item.labels?.reach ) labels.reach = this.item.labels.reach;
    }
    return labels;
  }

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

  /**
   * Abilities that could potentially be used with this attack. Unless a specific ability is specified then
   * whichever ability has the highest modifier will be selected when making an attack.
   * @type {Set<string>}
   */
  get availableAbilities() {
    // Defer to item if available and matching attack classification
    if ( this.item.system.availableAbilities && (this.item.type === this.attack.type.classification) ) {
      return this.item.system.availableAbilities;
    }

    // Spell attack not associated with a single class, use highest spellcasting ability on actor
    if ( this.attack.type.classification === "spell" ) return new Set(
      this.actor?.system.attributes?.spellcasting
        ? [this.actor.system.attributes.spellcasting]
        : Object.values(this.actor?.spellcastingClasses ?? {}).map(c => c.spellcasting.ability)
    );

    // Weapon & unarmed attacks uses melee or ranged ability depending on type, or both if actor is an NPC
    const melee = CONFIG.DND5E.defaultAbilities.meleeAttack;
    const ranged = CONFIG.DND5E.defaultAbilities.rangedAttack;
    return new Set([this.attack.type.value === "melee" ? melee : ranged]);
  }

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

  /**
   * Critical threshold for attacks with this activity.
   * @type {number}
   */
  get criticalThreshold() {
    // TODO: Fetch threshold from ammo
    const threshold = Math.min(
      this.attack.critical.threshold ?? Infinity,
      this.item.system.criticalThreshold ?? Infinity,
      Infinity
    );
    return threshold < Infinity ? threshold : 20;
  }

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

  /**
   * Potential attack types when attacking with this activity.
   * @type {Set<string>}
   */
  get validAttackTypes() {
    const sourceType = this._source.attack.type.value;
    if ( sourceType ) return new Set([sourceType]);
    return this.item.system.validAttackTypes ?? new Set();
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    // For weapons and ammunition, separate the first part from the rest to be used as the base damage and keep the rest
    let damageParts = source.system.damage?.parts ?? [];
    const hasBase = (source.type === "weapon")
      || ((source.type === "consumable") && (source.system?.type?.value === "ammo"));
    if ( hasBase && damageParts.length && !source.system.damage?.base ) {
      const [base, ...rest] = damageParts;
      source.system.damage.parts = [base];
      damageParts = rest;
    }

    return foundry.utils.mergeObject(activityData, {
      attack: {
        ability: source.system.ability ?? "",
        bonus: source.system.attack?.bonus ?? "",
        critical: {
          threshold: source.system.critical?.threshold
        },
        flat: source.system.attack?.flat ?? false,
        type: {
          value: source.system.actionType.startsWith("m") ? "melee" : "ranged",
          classification: source.system.actionType.endsWith("wak") ? "weapon" : "spell"
        }
      },
      damage: {
        critical: {
          bonus: source.system.critical?.damage
        },
        includeBase: true,
        parts: damageParts.map(part => this.transformDamagePartData(source, part)) ?? []
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    this.attack.type.value ||= this.item.system.attackType ?? "melee";
    this.attack.type.classification ||= this.item.system.attackClassification ?? "weapon";
  }

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

  /** @inheritDoc */
  prepareFinalData(rollData) {
    if ( this.damage.includeBase && this.item.system.offersBaseDamage && this.item.system.damage.base.formula ) {
      const basePart = this.item.system.damage.base.clone(this.item.system.damage.base.toObject(false));
      basePart.base = true;
      basePart.locked = true;
      this.damage.parts.unshift(basePart);
    }

    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel(this.damage.parts, rollData);

    const { data, parts } = this.getAttackData();
    const roll = new Roll(parts.join("+"), data);
    this.labels.modifier = simplifyRollFormula(roll.formula, { deterministic: true }) || "0";
    const formula = simplifyRollFormula(roll.formula) || "0";
    this.labels.toHit = !/^[+-]/.test(formula) ? `+${formula}` : formula;
  }

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

  /**
   * The game term label for this attack.
   * @param {string} [attackMode]  The mode the attack was made with.
   * @returns {string}
   */
  getActionLabel(attackMode) {
    let attackModeLabel;
    if ( attackMode ) {
      const key = attackMode.split("-").map(s => s.capitalize()).join("");
      attackModeLabel = game.i18n.localize(`DND5E.ATTACK.Mode.${key}`);
    }
    const actionType = this.getActionType(attackMode);
    let actionTypeLabel = game.i18n.localize(`DND5E.Action${actionType.toUpperCase()}`);
    const isLegacy = game.settings.get("dnd5e", "rulesVersion") === "legacy";
    const isUnarmed = this.attack.type.classification === "unarmed";
    if ( isUnarmed ) attackModeLabel = game.i18n.localize("DND5E.ATTACK.Classification.Unarmed");
    const isSpell = (actionType === "rsak") || (actionType === "msak");
    if ( isLegacy || isSpell ) return [actionTypeLabel, attackModeLabel].filterJoin(" &bull; ");
    actionTypeLabel = game.i18n.localize(`DND5E.ATTACK.Attack.${actionType}`);
    if ( isUnarmed ) return [actionTypeLabel, attackModeLabel].filterJoin(" &bull; ");
    const weaponType = CONFIG.DND5E.weaponTypeMap[this.item.system.type?.value];
    const weaponTypeLabel = weaponType
      ? game.i18n.localize(`DND5E.ATTACK.Weapon.${weaponType.capitalize()}`)
      : CONFIG.DND5E.weaponTypes[this.item.system.type?.value];
    return [actionTypeLabel, weaponTypeLabel, attackModeLabel].filterJoin(" &bull; ");
  }

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

  /**
   * Get the roll parts used to create the attack roll.
   * @param {object} [config={}]
   * @param {string} [config.ammunition]
   * @param {string} [config.attackMode]
   * @param {string} [config.situational]
   * @returns {{ data: object, parts: string[] }}
   */
  getAttackData({ ammunition, attackMode, situational }={}) {
    const rollData = this.getRollData();
    if ( this.attack.flat ) return CONFIG.Dice.BasicRoll.constructParts({ toHit: this.attack.bonus }, rollData);

    const weapon = this.item.system;
    const ammo = this.actor?.items.get(ammunition)?.system;
    const { parts, data } = CONFIG.Dice.BasicRoll.constructParts({
      mod: this.attack.ability !== "none" ? rollData.mod : null,
      prof: weapon.prof?.term,
      bonus: this.attack.bonus,
      weaponMagic: weapon.magicAvailable ? weapon.magicalBonus : null,
      ammoMagic: ammo?.magicAvailable ? ammo.magicalBonus : null,
      actorBonus: this.actor?.system.bonuses?.[this.getActionType(attackMode)]?.attack,
      situational
    }, rollData);

    // Add exhaustion reduction
    this.actor?.addRollExhaustion(parts, data);

    return { data, parts };
  }

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

  /**
   * @typedef {AttackDamageRollProcessConfiguration} [config={}]
   * @property {Item5e} ammunition  Ammunition used with the attack.
   * @property {"oneHanded"|"twoHanded"|"offhand"|"thrown"|"thrown-offhand"} attackMode  Attack mode.
   */

  /**
   * Get the roll parts used to create the damage rolls.
   * @param {Partial<AttackDamageRollProcessConfiguration>} [config={}]
   * @returns {AttackDamageRollProcessConfiguration}
   */
  getDamageConfig(config={}) {
    const rollConfig = super.getDamageConfig(config);

    // Handle ammunition
    const ammo = config.ammunition?.system;
    if ( ammo ) {
      const properties = Array.from(ammo.properties).filter(p => CONFIG.DND5E.itemProperties[p]?.isPhysical);
      if ( this.item.system.properties?.has("mgc") && !properties.includes("mgc") ) properties.push("mgc");

      // Add any new physical properties from the ammunition to the damage properties
      for ( const roll of rollConfig.rolls ) {
        for ( const property of properties ) {
          if ( !roll.options.properties.includes(property) ) roll.options.properties.push(property);
        }
      }

      // Add the ammunition's damage
      if ( ammo.damage.base.formula ) {
        const basePartIndex = rollConfig.rolls.findIndex(i => i.base);
        const damage = ammo.damage.base.clone(ammo.damage.base);
        const rollData = this.getRollData();

        // If mode is "replace" and base part is present, replace the base part
        if ( ammo.damage.replace & (basePartIndex !== -1) ) {
          damage.base = true;
          rollConfig.rolls.splice(basePartIndex, 1, this._processDamagePart(damage, config, rollData, basePartIndex));
        }

        // Otherwise stick the ammo damage after base part (or as first part)
        else {
          damage.ammo = true;
          rollConfig.rolls.splice(
            basePartIndex + 1, 0, this._processDamagePart(damage, rollConfig, rollData, basePartIndex + 1)
          );
        }
      }
    }

    if ( this.damage.critical.bonus && !rollConfig.rolls[0]?.options?.critical?.bonusDamage ) {
      foundry.utils.setProperty(rollConfig.rolls[0], "options.critical.bonusDamage", this.damage.critical.bonus);
    }

    return rollConfig;
  }

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

  /**
   * Create a label based on this activity's settings and, if contained in a weapon, additional details from the weapon.
   * @returns {string}
   */
  getRangeLabel() {
    if ( this.item.type !== "weapon" ) return this.labels?.range ?? "";

    const parts = [];

    // Add reach for melee weapons, unless the activity is explicitly specified as a ranged attack
    if ( this.validAttackTypes.has("melee") ) {
      let { reach, units } = this.item.system.range;
      if ( !reach ) reach = convertLength(5, "ft", units);
      parts.push(game.i18n.format("DND5E.RANGE.Formatted.Reach", {
        reach: formatLength(reach, units, { strict: false })
      }));
    }

    // Add range for ranged or thrown weapons, unless the activity is explicitly specified as melee
    if ( this.validAttackTypes.has("ranged") ) {
      let range;
      if ( this.range.override ) range = `${this.range.value} ${this.range.units ?? ""}`;
      else {
        const { value, long, units } = this.item.system.range;
        if ( long && (value !== long) ) range = `${value}/${formatLength(long, units, { strict: false })}`;
        else range = formatLength(value, units, { strict: false });
      }
      parts.push(game.i18n.format("DND5E.RANGE.Formatted.Range", { range }));
    }

    return game.i18n.getListFormatter({ type: "disjunction" }).format(parts.filter(_ => _));
  }

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

  /** @inheritDoc */
  _processDamagePart(damage, rollConfig, rollData, index=0) {
    if ( !damage.base ) return super._processDamagePart(damage, rollConfig, rollData, index);

    // Swap base damage for versatile if two-handed attack is made on versatile weapon
    if ( this.item.system.isVersatile && (rollConfig.attackMode === "twoHanded") ) {
      const versatile = this.item.system.damage.versatile.clone(this.item.system.damage.versatile);
      versatile.base = true;
      versatile.denomination ||= damage.steppedDenomination();
      versatile.number ||= damage.number;
      versatile.types = damage.types;
      damage = versatile;
    }

    const roll = super._processDamagePart(damage, rollConfig, rollData, index);
    roll.base = true;

    if ( this.item.type === "weapon" ) {
      // Ensure `@mod` is present in damage unless it is positive and an off-hand attack or damage is a flat value
      const isDeterministic = new Roll(roll.parts[0]).isDeterministic;
      const includeMod = (!rollConfig.attackMode?.endsWith("offhand") || (roll.data.mod < 0)) && !isDeterministic;
      if ( includeMod && !roll.parts.some(p => p.includes("@mod")) ) roll.parts.push("@mod");

      // Add magical bonus
      if ( this.item.system.magicalBonus && this.item.system.magicAvailable ) {
        roll.parts.push("@magicalBonus");
        roll.data.magicalBonus = this.item.system.magicalBonus;
      }

      // Add ammunition bonus
      const ammo = rollConfig.ammunition?.system;
      if ( ammo?.magicAvailable && ammo.magicalBonus ) {
        roll.parts.push("@ammoBonus");
        roll.data.ammoBonus = ammo.magicalBonus;
      }
    }

    const criticalBonusDice = this.actor?.getFlag("dnd5e", "meleeCriticalDamageDice") ?? 0;
    if ( (this.getActionType(rollConfig.attackMode) === "mwak") && (parseInt(criticalBonusDice) !== 0) ) {
      foundry.utils.setProperty(roll, "options.critical.bonusDice", criticalBonusDice);
    }

    return roll;
  }
}

/**
 * @typedef {BasicRollConfigurationDialogOptions} SkillToolRollConfigurationDialogOptions
 * @property {boolean} chooseAbility  Should the ability be selectable?
 */

/**
 * Extended roll configuration dialog that allows selecting abilities.
 */
class SkillToolRollConfigurationDialog extends D20RollConfigurationDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    chooseAbility: true
  };

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

  /** @inheritDoc */
  async _prepareConfigurationContext(context, options) {
    context = await super._prepareConfigurationContext(context, options);
    if ( this.options.chooseAbility ) context.fields.unshift({
      field: new foundry.data.fields.StringField({ label: game.i18n.localize("DND5E.Abilities") }),
      name: "ability",
      options: Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label })),
      value: this.config.ability
    });
    return context;
  }

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

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( this.config.skill && (event.target?.name === "ability") ) {
      const skillLabel = CONFIG.DND5E.skills[this.config.skill]?.label ?? "";
      const ability = event.target.value ?? this.config.ability;
      const abilityLabel = CONFIG.DND5E.abilities[ability]?.label ?? "";
      const flavor = game.i18n.format("DND5E.SkillPromptTitle", { skill: skillLabel, ability: abilityLabel });
      foundry.utils.setProperty(this.message, "data.flavor", flavor);
      this._updateFrame({ window: { title: flavor } });
    }
  }
}

const { DiceTerm: DiceTerm$1, NumericTerm: NumericTerm$1 } = foundry.dice.terms;

/**
 * Configuration data for the process of creating one or more basic rolls.
 *
 * @typedef {object} BasicRollProcessConfiguration
 * @property {BasicRollConfiguration[]} rolls  Configuration data for individual rolls.
 * @property {boolean} [evaluate=true]         Should the rolls be evaluated? If set to `false`, then no chat message
 *                                             will be created regardless of message configuration.
 * @property {Event} [event]                   Event that triggered the rolls.
 * @property {string[]} [hookNames]            Name suffixes for configuration hooks called.
 * @property {Document} [subject]              Document that initiated this roll.
 * @property {number} [target]                 Default target value for all rolls.
 */

/**
 * Configuration data for an individual roll.
 *
 * @typedef {object} BasicRollConfiguration
 * @property {string[]} [parts=[]]         Parts used to construct the roll formula.
 * @property {object} [data={}]            Data used to resolve placeholders in the formula.
 * @property {boolean} [situational=true]  Whether the situational bonus can be added to this roll in the prompt.
 * @property {BasicRollOptions} [options]  Additional options passed through to the created roll.
 */

/**
 * Options allowed on a basic roll.
 *
 * @typedef {object} BasicRollOptions
 * @property {number} [target]  The total roll result that must be met for the roll to be considered a success.
 */

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

/**
 * Configuration data for the roll prompt.
 *
 * @typedef {object} BasicRollDialogConfiguration
 * @property {boolean} [configure=true]  Display a configuration dialog for the rolling process.
 * @property {typeof RollConfigurationDialog} [applicationClass]  Alternate configuration application to use.
 * @property {BasicRollConfigurationDialogOptions} [options]      Additional options passed to the dialog.
 */

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

/**
 * Configuration data for creating a roll message.
 *
 * @typedef {object} BasicRollMessageConfiguration
 * @property {boolean} [create=true]     Create a message when the rolling is complete.
 * @property {ChatMessage5e} [document]  Final created chat message document once process is completed.
 * @property {string} [rollMode]         The roll mode to apply to this message from `CONFIG.Dice.rollModes`.
 * @property {object} [data={}]          Additional data used when creating the message.
 */

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

/**
 * Custom base roll type with methods for building rolls, presenting prompts, and creating messages.
 */
class BasicRoll extends Roll {

  /**
   * Default application used for the roll configuration prompt.
   * @type {typeof RollConfigurationDialog}
   */
  static DefaultConfigurationDialog = RollConfigurationDialog;

  /* -------------------------------------------- */
  /*  Static Construction                         */
  /* -------------------------------------------- */

  /**
   * Create a roll instance from a roll config.
   * @param {BasicRollConfiguration} config          Configuration info for the roll.
   * @param {BasicRollProcessConfiguration} process  Configuration info for the whole rolling process.
   * @returns {BasicRoll}
   */
  static fromConfig(config, process) {
    const formula = (config.parts ?? []).join(" + ");
    config.options ??= {};
    config.options.target ??= process.target;
    return new this(formula, config.data, config.options);
  }

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

  /**
   * Construct roll parts and populate its data object.
   * @param {object} parts   Information on the parts to be constructed.
   * @param {object} [data]  Roll data to use and populate while constructing the parts.
   * @returns {{ parts: string[], data: object }}
   */
  static constructParts(parts, data={}) {
    const finalParts = [];
    for ( const [key, value] of Object.entries(parts) ) {
      if ( !value && (value !== 0) ) continue;
      finalParts.push(`@${key}`);
      foundry.utils.setProperty(
        data, key, foundry.utils.getType(value) === "string" ? Roll.replaceFormulaData(value, data) : value
      );
    }
    return { parts: finalParts, data };
  }

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

  /**
   * Construct and perform a roll through the standard workflow.
   * @param {BasicRollProcessConfiguration} [config={}]   Configuration for the rolls.
   * @param {BasicRollDialogConfiguration} [dialog={}]    Configuration for roll prompt.
   * @param {BasicRollMessageConfiguration} [message={}]  Configuration for message creation.
   * @returns {BasicRoll[]}
   */
  static async build(config={}, dialog={}, message={}) {
    const rolls = await this.buildConfigure(config, dialog, message);
    await this.buildEvaluate(rolls, config, message);
    await this.buildPost(rolls, config, message);
    return rolls;
  }

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

  /**
   * Stage one of the standard rolling workflow, configuring the roll.
   * @param {BasicRollProcessConfiguration} [config={}]   Configuration for the rolls.
   * @param {BasicRollDialogConfiguration} [dialog={}]    Configuration for roll prompt.
   * @param {BasicRollMessageConfiguration} [message={}]  Configuration for message creation.
   * @returns {Promise<BasicRoll[]>}
   */
  static async buildConfigure(config={}, dialog={}, message={}) {
    config.hookNames = [...(config.hookNames ?? []), ""];

    /**
     * A hook event that fires before a roll is performed. Multiple hooks may be called depending on the rolling
     * method (e.g. `dnd5e.preRollSkillV2`, `dnd5e.preRollAbilityCheckV2`, `dnd5e.preRollV2`). Exact contents of the
     * configuration object will also change based on the roll type, but the same objects will always be present.
     * @function dnd5e.preRollV2
     * @memberof hookEvents
     * @param {BasicRollProcessConfiguration} config   Configuration data for the pending roll.
     * @param {BasicRollDialogConfiguration} dialog    Presentation data for the roll configuration dialog.
     * @param {BasicRollMessageConfiguration} message  Configuration data for the roll's message.
     * @returns {boolean}                              Explicitly return `false` to prevent the roll.
     */
    for ( const hookName of config.hookNames ) {
      if ( Hooks.call(`dnd5e.preRoll${hookName.capitalize()}V2`, config, dialog, message) === false ) return [];
    }

    this.applyKeybindings(config, dialog, message);

    let rolls;
    if ( dialog.configure === false ) {
      rolls = config.rolls?.map((r, index) => {
        dialog.options?.buildConfig?.(config, r, null, index);
        return this.fromConfig(r, config);
      }) ?? [];
    } else {
      const DialogClass = dialog.applicationClass ?? this.DefaultConfigurationDialog;
      rolls = await DialogClass.configure(config, dialog, message);
    }

    // Store the roll type in roll.options so it can be accessed from only the roll
    const rollType = foundry.utils.getProperty(message, "data.flags.dnd5e.roll.type");
    if ( rollType ) rolls.forEach(roll => roll.options.rollType ??= rollType);

    /**
     * A hook event that fires after roll configuration is complete, but before the roll is evaluated.
     * Multiple hooks may be called depending on the rolling method (e.g. `dnd5e.postSkillCheckRollConfiguration`,
     * `dnd5e.postAbilityTestRollConfiguration`, and `dnd5e.postRollConfiguration` for skill checks). Exact contents of
     * the configuration object will also change based on the roll type, but the same objects will always be present.
     * @function dnd5e.postRollConfiguration
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls                      Rolls that have been constructed but not evaluated.
     * @param {BasicRollProcessConfiguration} config   Configuration information for the roll.
     * @param {BasicRollDialogConfiguration} dialog    Configuration for the roll dialog.
     * @param {BasicRollMessageConfiguration} message  Configuration for the roll message.
     * @returns {boolean}                              Explicitly return `false` to prevent rolls.
     */
    for ( const hookName of config.hookNames ) {
      const name = `dnd5e.post${hookName.capitalize()}RollConfiguration`;
      if ( Hooks.call(name, rolls, config, dialog, message) === false ) return [];
    }

    return rolls;
  }

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

  /**
   * Stage two of the standard rolling workflow, evaluating the rolls.
   * @param {BasicRoll[]} rolls                           Rolls to evaluate.
   * @param {BasicRollProcessConfiguration} [config={}]   Configuration for the rolls.
   * @param {BasicRollMessageConfiguration} [message={}]  Configuration for message creation.
   */
  static async buildEvaluate(rolls, config={}, message={}) {
    if ( config.evaluate !== false ) {
      for ( const roll of rolls ) await roll.evaluate();
    }
  }

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

  /**
   * Stage three of the standard rolling workflow, posting a message to chat.
   * @param {BasicRoll[]} rolls                      Rolls to evaluate.
   * @param {BasicRollProcessConfiguration} config   Configuration for the rolls.
   * @param {BasicRollMessageConfiguration} message  Configuration for message creation.
   * @returns {ChatMessage5e|void}
   */
  static async buildPost(rolls, config, message) {
    message.data = foundry.utils.expandObject(message.data ?? {});
    const messageId = config.event?.target.closest("[data-message-id]")?.dataset.messageId;
    if ( messageId ) foundry.utils.setProperty(message.data, "flags.dnd5e.originatingMessage", messageId);

    if ( rolls?.length && (config.evaluate !== false) && (message.create !== false) ) {
      message.document = await this.toMessage(rolls, message.data, { rollMode: message.rollMode });
    }

    return message.document;
  }

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

  /**
   * Determines whether the roll process should be fast forwarded.
   * @param {BasicRollProcessConfiguration} config   Roll configuration data.
   * @param {BasicRollDialogConfiguration} dialog    Data for the roll configuration dialog.
   * @param {BasicRollMessageConfiguration} message  Message configuration data.
   */
  static applyKeybindings(config, dialog, message) {
    dialog.configure ??= true;
  }

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

  /**
   * Is the result of this roll a failure? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isFailure() {
    if ( !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.target) ) return false;
    return this.total < this.options.target;
  }

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

  /**
   * Is the result of this roll a success? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isSuccess() {
    if ( !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.target) ) return false;
    return this.total >= this.options.target;
  }

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

  /**
   * 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 it.
   *
   * @param {BasicRoll[]} rolls              Rolls to add to the message.
   * @param {object} messageData             The data object to use when creating the message.
   * @param {options} [options]              Additional options which modify the created message.
   * @param {string} [options.rollMode]      The template roll mode to use for the message from CONFIG.Dice.rollModes
   * @param {boolean} [options.create=true]  Whether to automatically create the chat message, or only return the
   *                                         prepared chatData object.
   * @returns {Promise<ChatMessage|object>}  A promise which resolves to the created ChatMessage document if create is
   *                                         true, or the Object of prepared chatData otherwise.
   */
  static async toMessage(rolls, messageData={}, { rollMode, create=true }={}) {
    for ( const roll of rolls ) {
      if ( !roll._evaluated ) await roll.evaluate({ allowInteractive: rollMode !== CONST.DICE_ROLL_MODES.BLIND });
      rollMode ??= roll.options.rollMode;
    }

    // Prepare chat data
    messageData = foundry.utils.mergeObject({ sound: CONFIG.sounds.dice }, messageData);
    messageData.rolls = rolls;
    this._prepareMessageData(rolls, messageData);

    // Process the chat data
    const cls = getDocumentClass("ChatMessage");
    const msg = new cls(messageData);

    // Either create or return the data
    if ( create ) return cls.create(msg.toObject(), { rollMode });
    else {
      if ( rollMode ) msg.applyRollMode(rollMode);
      return msg.toObject();
    }
  }

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

  /**
   * Perform specific changes to message data before creating message.
   * @param {BasicRoll[]} rolls   Rolls to add to the message.
   * @param {object} messageData  The data object to use when creating the message.
   * @protected
   */
  static _prepareMessageData(rolls, messageData) {}

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

  /** @inheritDoc */
  async evaluate(options={}) {
    this.preCalculateDiceTerms(options);
    return super.evaluate(options);
  }

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

  /** @inheritDoc */
  evaluateSync(options={}) {
    this.preCalculateDiceTerms(options);
    return super.evaluateSync(options);
  }

  /* -------------------------------------------- */
  /*  Roll Formula Parsing                        */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static replaceFormulaData(formula, data, options) {
    // This looks for the pattern `$!!$` and replaces it with just the value between the marks (the bang has
    // been added to ensure this is a deliberate shim from the system, not a unintentional usage that should
    // show an error).
    return super.replaceFormulaData(formula, data, options).replaceAll(/\$"?!(.+?)!"?\$/g, "$1");
  }

  /* -------------------------------------------- */
  /*  Maximize/Minimize Methods                   */
  /* -------------------------------------------- */

  /**
   * Replaces all dice terms that have modifiers with their maximum/minimum value.
   *
   * @param {object} [options={}]            Extra optional arguments which describe or modify the BasicRoll.
   */
  preCalculateDiceTerms(options={}) {
    if ( this._evaluated || (!options.maximize && !options.minimize) ) return;
    this.terms = this.terms.map(term => {
      if ( (term instanceof DiceTerm$1) && term.modifiers.length ) {
        const minimize = !options.maximize;
        const number = this.constructor.preCalculateTerm(term, { minimize });
        if ( Number.isFinite(number) ) return new NumericTerm$1({ number, options: term.options });
      }
      return term;
    });
  }

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

  /**
   * Gets information from passed die and calculates the maximum or minimum value that could be rolled.
   *
   * @param {DiceTerm} die                            DiceTerm to get the maximum/minimum value.
   * @param {object} [preCalculateOptions={}]         Additional options to modify preCalculate functionality.
   * @param {boolean} [preCalculateOptions.minimize=false]  Calculate the minimum value instead of the maximum.
   * @returns {number|null}                                 Maximum/Minimum value that could be rolled as an integer, or
   *                                                        null if the modifiers could not be precalculated.
   */
  static preCalculateTerm(die, { minimize=false }={}) {
    let face = minimize ? 1 : die.faces;
    let number = die.number;
    const currentModifiers = foundry.utils.deepClone(die.modifiers);
    const keep = new Set(["k", "kh", "kl"]);
    const drop = new Set(["d", "dh", "dl"]);
    const validModifiers = new Set([...keep, ...drop, "max", "min"]);
    let matchedModifier = false;

    for ( const modifier of currentModifiers ) {
      const rgx = /(m[ai][xn]|[kd][hl]?)(\d+)?/i;
      const match = modifier.match(rgx);
      if ( !match ) continue;
      if ( match[0].length < match.input.length ) currentModifiers.push(match.input.slice(match[0].length));
      let [, command, value] = match;
      command = command.toLowerCase();
      if ( !validModifiers.has(command) ) continue;

      matchedModifier = true;
      const amount = parseInt(value) || (command === "max" || command === "min" ? -1 : 1);
      if ( amount > 0 ) {
        if ( (command === "max" && minimize) || (command === "min" && !minimize) ) continue;
        else if ( (command === "max" || command === "min") ) face = Math.min(die.faces, amount);
        else if ( keep.has(command) ) number = Math.min(number, amount);
        else if ( drop.has(command) ) number = Math.max(1, number - amount);
      }
    }

    return matchedModifier ? face * number : null;
  }

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

  /**
   * Replace number and faces of dice terms with numeric values where possible.
   */
  simplify() {
    for ( const die of this.dice ) {
      const n = die._number;
      if ( (n instanceof BasicRoll) && n.isDeterministic ) die._number = n.evaluateSync().total;
      const f = die._faces;
      if ( (f instanceof BasicRoll) && f.isDeterministic ) die._faces = f.evaluateSync().total;

      // Preserve flavor.
      if ( f.terms?.[0]?.flavor ) die.options.flavor = f.terms[0].flavor;
    }

    this.resetFormula();
  }

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

  /**
   * Merge two roll configurations.
   * @param {Partial<BasicRollConfiguration>} original  The initial configuration that will be merged into.
   * @param {Partial<BasicRollConfiguration>} other     The configuration to merge.
   * @returns {Partial<BasicRollConfiguration>}         The original instance.
   */
  static mergeConfigs(original, other={}) {
    if ( other.data ) {
      original.data ??= {};
      Object.assign(original.data, other.data);
    }

    if ( other.parts?.length ) {
      original.parts ??= [];
      original.parts.unshift(...other.parts);
    }

    if ( other.options ) {
      original.options ??= {};
      foundry.utils.mergeObject(original.options, other.options);
    }

    return original;
  }
}

/**
 * Configuration data for the process of rolling d20 rolls.
 *
 * @typedef {BasicRollProcessConfiguration} D20RollProcessConfiguration
 * @property {boolean} [advantage]             Apply advantage to each roll.
 * @property {boolean} [disadvantage]          Apply disadvantage to each roll.
 * @property {boolean} [elvenAccuracy]         Use three dice when rolling with advantage.
 * @property {boolean} [halflingLucky]         Add a re-roll once modifier to the d20 die.
 * @property {boolean} [reliableTalent]        Set the minimum for the d20 roll to 10.
 * @property {D20RollConfiguration[]} rolls    Configuration data for individual rolls.
 */

/**
 * D20 roll configuration data.
 *
 * @typedef {BasicRollConfiguration} D20RollConfiguration
 * @property {string[]} parts          Parts used to construct the roll formula, not including the d20 die.
 * @property {D20RollOptions} options  Options passed through to the roll.
 */

/**
 * Options that describe a d20 roll.
 *
 * @typedef {BasicRollOptions} D20RollOptions
 * @property {boolean} [advantage]       Does this roll potentially have advantage?
 * @property {boolean} [disadvantage]    Does this roll potentially have disadvantage?
 * @property {D20Roll.ADV_MODE} [advantageMode]  Final advantage mode.
 * @property {number} [criticalSuccess]  The value of the d20 die to be considered a critical success.
 * @property {number} [criticalFailure]  The value of the d20 die to be considered a critical failure.
 * @property {boolean} [elvenAccuracy]   Use three dice when rolling with advantage.
 * @property {boolean} [halflingLucky]   Add a re-roll once modifier to the d20 die.
 * @property {number} [maximum]          Maximum number the d20 die can roll.
 * @property {number} [minimum]          Minimum number the d20 die can roll.
 */

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

/**
 * A type of Roll specific to a d20-based check, save, or attack roll in the 5e system.
 * @param {string} formula          The string formula to parse.
 * @param {object} data             The data object against which to parse attributes within the formula.
 * @param {D20RollOptions} options  Additional options that describe the d20 roll.
 */
class D20Roll extends BasicRoll {
  constructor(formula, data, options) {
    super(formula, data, options);
    this.#createD20Die();
    if ( !this.options.configured ) this.configureModifiers();
  }

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

  /**
   * Advantage mode of a 5e d20 roll
   * @enum {number}
   */
  static ADV_MODE = {
    NORMAL: 0,
    ADVANTAGE: 1,
    DISADVANTAGE: -1
  };

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

  /** @inheritDoc */
  static DefaultConfigurationDialog = D20RollConfigurationDialog;

  /* -------------------------------------------- */
  /*  Static Construction                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static fromConfig(config, process) {
    const formula = [new CONFIG.Dice.D20Die().formula].concat(config.parts ?? []).join(" + ");
    config.options.criticalSuccess ??= CONFIG.Dice.D20Die.CRITICAL_SUCCESS_TOTAL;
    config.options.criticalFailure ??= CONFIG.Dice.D20Die.CRITICAL_FAILURE_TOTAL;
    config.options.elvenAccuracy ??= process.elvenAccuracy;
    config.options.halflingLucky ??= process.halflingLucky;
    config.options.reliableTalent ??= process.reliableTalent;
    config.options.target ??= process.target;
    return new this(formula, config.data, config.options);
  }

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

  /**
   * Create a D20Roll from a standard Roll instance.
   * @param {Roll} roll
   * @returns {D20Roll}
   */
  static fromRoll(roll) {
    const newRoll = new this(roll.formula, roll.data, roll.options);
    Object.assign(newRoll, roll);
    return newRoll;
  }

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

  /**
   * Determines whether the roll should be fast forwarded and what the default advantage mode should be.
   * @param {D20RollProcessConfiguration} config     Roll configuration data.
   * @param {BasicRollDialogConfiguration} dialog    Data for the roll configuration dialog.
   * @param {BasicRollMessageConfiguration} message  Configuration data that guides roll message creation.
   */
  static applyKeybindings(config, dialog, message) {
    const keys = {
      normal: areKeysPressed(config.event, "skipDialogNormal"),
      advantage: areKeysPressed(config.event, "skipDialogAdvantage"),
      disadvantage: areKeysPressed(config.event, "skipDialogDisadvantage")
    };

    // Should the roll configuration dialog be displayed?
    dialog.configure ??= !Object.values(keys).some(k => k);

    // Determine advantage mode
    for ( const roll of config.rolls ?? [] ) {
      const advantage = roll.options.advantage || config.advantage || keys.advantage;
      const disadvantage = roll.options.disadvantage || config.disadvantage || keys.disadvantage;
      if ( advantage && !disadvantage ) roll.options.advantageMode = this.ADV_MODE.ADVANTAGE;
      else if ( !advantage && disadvantage ) roll.options.advantageMode = this.ADV_MODE.DISADVANTAGE;
      else roll.options.advantageMode = this.ADV_MODE.NORMAL;
    }
  }

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

  /**
   * The primary die used in this d20 roll.
   * @type {D20Die|void}
   */
  get d20() {
    if ( !(this.terms[0] instanceof foundry.dice.terms.Die) ) return;
    if ( !(this.terms[0] instanceof CONFIG.Dice.D20Die) ) this.#createD20Die();
    return this.terms[0];
  }

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

  /**
   * Set the d20 for this roll.
   */
  set d20(die) {
    if ( !(die instanceof CONFIG.Dice.D20Die) ) throw new Error(
      `D20 die must be an instance of ${CONFIG.Dice.D20Die.name}, instead a ${die.constructor.name} was provided.`
    );
    this.terms[0] = die;
  }

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

  /**
   * A convenience reference for whether this D20Roll has advantage.
   * @type {boolean}
   */
  get hasAdvantage() {
    return this.options.advantageMode === this.constructor.ADV_MODE.ADVANTAGE;
  }

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

  /**
   * A convenience reference for whether this D20Roll has disadvantage.
   * @type {boolean}
   */
  get hasDisadvantage() {
    return this.options.advantageMode === this.constructor.ADV_MODE.DISADVANTAGE;
  }

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

  /**
   * Is this roll a critical success? Returns undefined if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isCritical() {
    return this.d20.isCriticalSuccess;
  }

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

  /**
   * Is this roll a critical failure? Returns undefined if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isFumble() {
    return this.d20.isCriticalFailure;
  }

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

  /**
   * Does this roll start with a d20?
   * @type {boolean}
   */
  get validD20Roll() {
    return (this.d20 instanceof CONFIG.Dice.D20Die) && this.d20.isValid;
  }

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

  /** @override */
  static _prepareMessageData(rolls, messageData) {
    let advantage = true;
    let disadvantage = true;

    const rtLabel = game.i18n.localize("DND5E.FlagsReliableTalent");
    for ( const roll of rolls ) {
      if ( !roll.validD20Roll ) continue;
      if ( !roll.hasAdvantage ) advantage = false;
      if ( !roll.hasDisadvantage ) disadvantage = false;
      if ( roll.options.reliableTalent && roll.d20.results.every(r => !r.active || (r.result < 10)) ) {
        roll.d20.options.flavor = roll.d20.options.flavor ? `${roll.d20.options.flavor} (${rtLabel})` : rtLabel;
      }
    }

    messageData.flavor ??= "";
    if ( advantage ) messageData.flavor += ` (${game.i18n.localize("DND5E.Advantage")})`;
    else if ( disadvantage ) messageData.flavor += ` (${game.i18n.localize("DND5E.Disadvantage")})`;
  }

  /* -------------------------------------------- */
  /*  Roll Configuration                          */
  /* -------------------------------------------- */

  /**
   * Apply optional modifiers which customize the behavior of the d20term
   * @private
   */
  configureModifiers() {
    if ( !this.validD20Roll ) return;

    if ( this.options.advantageMode === undefined ) {
      const { advantage, disadvantage } = this.options;
      if ( advantage && !disadvantage ) this.options.advantageMode = this.constructor.ADV_MODE.ADVANTAGE;
      else if ( !advantage && disadvantage ) this.options.advantageMode = this.constructor.ADV_MODE.DISADVANTAGE;
      else this.options.advantageMode = this.constructor.ADV_MODE.NORMAL;
    }

    // Determine minimum, taking reliable talent into account
    let minimum = this.options.minimum;
    if ( this.options.reliableTalent ) minimum = Math.max(minimum ?? -Infinity, 10);

    // Directly modify the d20
    this.d20.applyFlag("elvenAccuracy", this.options.elvenAccuracy === true);
    this.d20.applyFlag("halflingLucky", this.options.halflingLucky === true);
    this.d20.applyAdvantage(this.options.advantageMode);
    this.d20.applyRange({ minimum, maximum: this.options.maximum });

    // Assign critical and fumble thresholds
    if ( this.options.criticalSuccess ) this.d20.options.criticalSuccess = this.options.criticalSuccess;
    if ( this.options.criticalFailure ) this.d20.options.criticalFailure = this.options.criticalFailure;
    if ( this.options.target ) this.d20.options.target = this.options.target;

    // Re-compile the underlying formula
    this.resetFormula();

    // Mark configuration as complete
    this.options.configured = true;
  }

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

  /**
   * Ensure the d20 die for this roll is actually a D20Die instance.
   */
  #createD20Die() {
    if ( this.terms[0] instanceof CONFIG.Dice.D20Die ) return;
    if ( !(this.terms[0] instanceof foundry.dice.terms.Die) ) return;
    const { number, faces, ...data } = this.terms[0];
    this.terms[0] = new CONFIG.Dice.D20Die({ ...data, number, faces });
  }

  /* -------------------------------------------- */
  /*  Configuration Dialog                        */
  /* -------------------------------------------- */

  /**
   * Create a Dialog prompt used to configure evaluation of an existing D20Roll instance.
   * @param {object} data                     Dialog configuration data
   * @param {string} [data.title]             The title of the shown dialog window
   * @param {number} [data.defaultRollMode]   The roll mode that the roll mode select element should default to
   * @param {number} [data.defaultAction]     The button marked as default
   * @param {FormSelectOption[]} [data.ammunitionOptions]  Selectable ammunition options.
   * @param {FormSelectOption[]} [data.attackModes]        Selectable attack modes.
   * @param {boolean} [data.chooseModifier]   Choose which ability modifier should be applied to the roll?
   * @param {string} [data.defaultAbility]    For tool rolls, the default ability modifier applied to the roll
   * @param {FormSelectOption[]} [data.masteryOptions]     Selectable weapon masteries.
   * @param {string} [data.template]          A custom path to an HTML template to use instead of the default
   * @param {object} options                  Additional Dialog customization options
   * @returns {Promise<D20Roll|null>}         A resulting D20Roll object constructed with the dialog, or null if the
   *                                          dialog was closed
   */
  async configureDialog({
    title, defaultRollMode, defaultAction=D20Roll.ADV_MODE.NORMAL, ammunitionOptions,
    attackModes, chooseModifier=false, defaultAbility, masteryOptions, template
  }={}, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `configureDialog` on D20Roll has been deprecated and is now handled through `D20Roll.build`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.5" }
    );
    let DialogClass = this.constructor.DefaultConfigurationDialog;
    if ( chooseModifier ) DialogClass = SkillToolRollConfigurationDialog;
    else if ( ammunitionOptions || attackModes || masteryOptions ) DialogClass = AttackRollConfigurationDialog;
    const defaultButton = {
      [D20Roll.ADV_MODE.NORMAL]: "normal",
      [D20Roll.ADV_MODE.ADVANTAGE]: "advantage",
      [D20Roll.ADV_MODE.DISADVANTAGE]: "disadvantage"
    }[String(defaultAction ?? "0")];
    return await DialogClass.configure(
      { rolls: [{ parts: [this.formula.replace(roll.d20.formula, "")], options: this.options }] },
      { options: { ammunitionOptions, attackModes, defaultButton, masteryOptions, title } },
      { rollMode: defaultRollMode }
    );
  }
}

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

/**
 * Translate new config objects back into old config objects for deprecated hooks.
 * @param {D20RollProcessConfiguration} rollConfig
 * @param {BasicRollDialogConfiguration} dialogConfig
 * @param {BasicRollMessageConfiguration} messageConfig
 * @returns {DeprecatedD20RollConfiguration}
 * @internal
 */
function _createDeprecatedD20Config(rollConfig, dialogConfig, messageConfig) {
  const oldConfig = {
    parts: rollConfig.rolls[0].parts,
    data: rollConfig.rolls[0].data,
    event: rollConfig.event,
    advantage: rollConfig.rolls[0].options?.advantage,
    disadvantage: rollConfig.rolls[0].options?.disadvantage,
    critical: rollConfig.rolls[0].options?.criticalSuccess,
    fumble: rollConfig.rolls[0].options?.criticalFailure,
    targetValue: rollConfig.target,
    ammunition: rollConfig.ammunition,
    attackMode: rollConfig.attackMode,
    mastery: rollConfig.mastery,
    elvenAccuracy: rollConfig.elvenAccuracy,
    halflingLucky: rollConfig.halflingLucky,
    reliableTalent: rollConfig.reliableTalent,
    ammunitionOptions: dialogConfig.options?.ammunitionOptions,
    attackModes: dialogConfig.options?.attackModeOptions,
    chooseModifier: dialogConfig.options?.chooseAbility,
    masteryOptions: dialogConfig?.options?.masteryOptions,
    title: dialogConfig.options?.title,
    dialogOptions: dialogConfig.options,
    chatMessage: messageConfig.create,
    messageData: messageConfig.data,
    rollMode: messageConfig.rollMode,
    flavor: messageConfig.data?.flavor
  };
  if ( "configure" in dialogConfig ) oldConfig.fastForward = !dialogConfig.configure;
  return oldConfig;
}

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

/**
 * Apply changes from old config objects back onto new config objects.
 * @param {D20RollProcessConfiguration} rollConfig
 * @param {BasicRollDialogConfiguration} dialogConfig
 * @param {BasicRollMessageConfiguration} messageConfig
 * @param {DeprecatedD20RollConfiguration} options
 * @internal
 */
function _applyDeprecatedD20Configs(rollConfig, dialogConfig, messageConfig, options) {
  const set = (config, keyPath, value) => {
    if ( value === undefined ) return;
    foundry.utils.setProperty(config, keyPath, value);
  };

  let roll = rollConfig.rolls?.[0] ?? {};
  set(roll, "parts", options.parts);
  set(roll, "data", options.data);
  set(rollConfig, "event", options.event);
  set(roll, "options.advantage", options.advantage);
  set(roll, "options.disadvantage", options.disadvantage);
  set(roll, "options.criticalSuccess", options.critical);
  set(roll, "options.criticalFailure", options.fumble);
  set(rollConfig, "target", options.targetValue);
  set(rollConfig, "ammunition", options.ammunition);
  set(rollConfig, "attackMode", options.attackMode);
  set(rollConfig, "mastery", options.mastery);
  set(rollConfig, "elvenAccuracy", options.elvenAccuracy);
  set(rollConfig, "halflingLucky", options.halflingLucky);
  set(rollConfig, "reliableTalent", options.reliableTalent);
  if ( "fastForward" in options ) dialogConfig.configure = !options.fastForward;
  set(dialogConfig, "options", options.dialogOptions);
  set(dialogConfig, "options.ammunitionOptions", options.ammunitionOptions);
  set(dialogConfig, "options.attackModeOptions", options.attackModes);
  set(dialogConfig, "options.chooseAbility", options.chooseModifier);
  set(dialogConfig, "options.masteryOptions", options.masteryOptions);
  set(dialogConfig, "options.title", options.title);
  set(messageConfig, "create", options.chatMessage);
  set(messageConfig, "data", options.messageData);
  set(messageConfig, "rollMode", options.rollMode);
  set(messageConfig, "data.flavor", options.flavor);

  if ( !foundry.utils.isEmpty(roll) ) {
    rollConfig.rolls ??= [];
    if ( rollConfig.rolls[0] ) rollConfig.rolls[0] = roll;
    else rollConfig.rolls.push(roll);
  }
}

/**
 * Activity for making attacks and rolling damage.
 */
class AttackActivity extends ActivityMixin(AttackActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.ATTACK"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "attack",
      img: "systems/dnd5e/icons/svg/activity/attack.svg",
      title: "DND5E.ATTACK.Title.one",
      sheetClass: AttackSheet,
      usage: {
        actions: {
          rollAttack: AttackActivity.#rollAttack,
          rollDamage: AttackActivity.#rollDamage
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    const buttons = [{
      label: game.i18n.localize("DND5E.Attack"),
      icon: '<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/trait-weapon-proficiencies.svg" inert></i>',
      dataset: {
        action: "rollAttack"
      }
    }];
    if ( this.damage.parts.length || this.item.system.properties?.has("amm") ) buttons.push({
      label: game.i18n.localize("DND5E.Damage"),
      icon: '<i class="fa-solid fa-burst" inert></i>',
      dataset: {
        action: "rollDamage"
      }
    });
    return buttons.concat(super._usageChatButtons(message));
  }

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

  /** @override */
  async _triggerSubsequentActions(config, results) {
    this.rollAttack({ event: config.event }, {}, { data: { "flags.dnd5e.originatingMessage": results.message?.id } });
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /**
   * @typedef {D20RollProcessConfiguration} AttackRollProcessConfiguration
   * @property {string|boolean} [ammunition]  Specific ammunition to consume, or `false` to prevent any ammo usage.
   * @property {string} [attackMode]          Mode to use for making the attack and rolling damage.
   * @property {string} [mastery]             Weapon mastery option to use.
   */

  /**
   * @typedef {BasicRollDialogConfiguration} AttackRollDialogConfiguration
   * @property {AttackRollConfigurationDialogOptions} [options]  Configuration options.
   */

  /**
   * @typedef {object} AmmunitionUpdate
   * @property {string} id        ID of the ammunition item to update.
   * @property {boolean} destroy  Will the ammunition item be deleted?
   * @property {number} quantity  New quantity after the ammunition is spent.
   */

  /**
   * Perform an attack roll.
   * @param {AttackRollProcessConfiguration} config  Configuration information for the roll.
   * @param {AttackRollDialogConfiguration} dialog   Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} message  Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}
   */
  async rollAttack(config={}, dialog={}, message={}) {
    const targets = getTargetDescriptors();

    if ( (this.item.type === "weapon") && (this.item.system.quantity === 0) ) {
      ui.notifications.warn("DND5E.ATTACK.Warning.NoQuantity", { localize: true });
    }

    const buildConfig = this._buildAttackConfig.bind(this);

    const rollConfig = foundry.utils.mergeObject({
      ammunition: this.item.getFlag("dnd5e", `last.${this.id}.ammunition`),
      attackMode: this.item.getFlag("dnd5e", `last.${this.id}.attackMode`),
      elvenAccuracy: this.actor?.getFlag("dnd5e", "elvenAccuracy")
        && CONFIG.DND5E.characterFlags.elvenAccuracy.abilities.includes(this.ability),
      halflingLucky: this.actor?.getFlag("dnd5e", "halflingLucky"),
      mastery: this.item.getFlag("dnd5e", `last.${this.id}.mastery`),
      target: targets.length === 1 ? targets[0].ac : undefined
    }, config);

    const ammunitionOptions = this.item.system.ammunitionOptions ?? [];
    if ( ammunitionOptions.length ) ammunitionOptions.unshift({ value: "", label: "" });
    if ( rollConfig.ammunition === undefined ) rollConfig.ammunition = ammunitionOptions?.[1]?.value;
    else if ( !ammunitionOptions?.find(m => m.value === rollConfig.ammunition) ) {
      rollConfig.ammunition = ammunitionOptions?.[0]?.value;
    }
    const attackModeOptions = this.item.system.attackModes;
    if ( !attackModeOptions?.find(m => m.value === rollConfig.attackMode) ) {
      rollConfig.attackMode = attackModeOptions?.[0]?.value;
    }
    const masteryOptions = this.item.system.masteryOptions;
    if ( !masteryOptions?.find(m => m.value === rollConfig.mastery) ) {
      rollConfig.mastery = masteryOptions?.[0]?.value;
    }

    rollConfig.hookNames = [...(config.hookNames ?? []), "attack", "d20Test"];
    rollConfig.rolls = [BasicRoll.mergeConfigs({
      options: {
        ammunition: rollConfig.ammunition,
        attackMode: rollConfig.attackMode,
        criticalSuccess: this.criticalThreshold,
        mastery: rollConfig.mastery
      }
    }, config.rolls?.shift())].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      applicationClass: AttackRollConfigurationDialog,
      options: {
        ammunitionOptions: rollConfig.ammunition !== false ? ammunitionOptions : [],
        attackModeOptions,
        buildConfig,
        masteryOptions: (masteryOptions?.length > 1) && !config.mastery ? masteryOptions : [],
        position: {
          top: config.event ? config.event.clientY - 80 : null,
          left: window.innerWidth - 710
        },
        window: {
          title: game.i18n.localize("DND5E.AttackRoll"),
          subtitle: this.item.name,
          icon: this.item.img
        }
      }
    }, dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flavor: `${this.item.name} - ${game.i18n.localize("DND5E.AttackRoll")}`,
        flags: {
          dnd5e: {
            ...this.messageFlags,
            messageType: "roll",
            roll: { type: "attack" }
          }
        },
        speaker: ChatMessage.getSpeaker({ actor: this.actor })
      }
    }, message);

    if ( "dnd5e.preRollAttack" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preRollAttack` hook has been deprecated and replaced with `dnd5e.preRollAttackV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      const oldConfig = _createDeprecatedD20Config(rollConfig, dialogConfig, messageConfig);
      if ( Hooks.call("dnd5e.preRollAttack", this.item, oldConfig) === false ) return null;
      _applyDeprecatedD20Configs(rollConfig, dialogConfig, messageConfig, oldConfig);
    }

    const rolls = await CONFIG.Dice.D20Roll.buildConfigure(rollConfig, dialogConfig, messageConfig);
    await CONFIG.Dice.D20Roll.buildEvaluate(rolls, rollConfig, messageConfig);
    if ( !rolls.length ) return null;
    for ( const key of ["ammunition", "attackMode", "mastery"] ) {
      if ( !rolls[0].options[key] ) continue;
      foundry.utils.setProperty(messageConfig.data, `flags.dnd5e.roll.${key}`, rolls[0].options[key]);
    }
    await CONFIG.Dice.D20Roll.buildPost(rolls, rollConfig, messageConfig);

    const flags = {};
    let ammoUpdate = null;

    if ( rolls[0].options.ammunition ) {
      const ammo = this.actor?.items.get(rolls[0].options.ammunition);
      if ( ammo ) {
        if ( !ammo.system.properties?.has("ret") ) {
          ammoUpdate = { id: ammo.id, quantity: Math.max(0, ammo.system.quantity - 1) };
          ammoUpdate.destroy = ammo.system.uses.autoDestroy && (ammoUpdate.quantity === 0);
        }
        flags.ammunition = rolls[0].options.ammunition;
      }
    } else if ( rolls[0].options.attackMode?.startsWith("thrown") && !this.item.system.properties?.has("ret") ) {
      ammoUpdate = { id: this.item.id, quantity: Math.max(0, this.item.system.quantity - 1) };
    } else if ( !rolls[0].options.ammunition && dialogConfig.options?.ammunitionOptions?.length ) {
      flags.ammunition = "";
    }
    if ( rolls[0].options.attackMode ) flags.attackMode = rolls[0].options.attackMode;
    else if ( rollConfig.attackMode ) rolls[0].options.attackMode = rollConfig.attackMode;
    if ( rolls[0].options.mastery ) flags.mastery = rolls[0].options.mastery;
    if ( !foundry.utils.isEmpty(flags) && this.actor.items.has(this.item.id) ) {
      await this.item.setFlag("dnd5e", `last.${this.id}`, flags);
    }

    /**
     * A hook event that fires after an attack has been rolled but before any ammunition is consumed.
     * @function dnd5e.rollAttackV2
     * @memberof hookEvents
     * @param {D20Roll[]} rolls                        The resulting rolls.
     * @param {object} data
     * @param {AttackActivity|null} data.subject       The Activity that performed the attack.
     * @param {AmmunitionUpdate|null} data.ammoUpdate  Any updates related to ammo consumption for this attack.
     */
    Hooks.callAll("dnd5e.rollAttackV2", rolls, { subject: this, ammoUpdate });

    if ( "dnd5e.rollAttack" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.rollAttack` hook has been deprecated and replaced with `dnd5e.rollAttackV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      const oldAmmoUpdate = ammoUpdate ? [{ _id: ammoUpdate.id, "system.quantity": ammoUpdate.quantity }] : [];
      Hooks.callAll("dnd5e.rollAttack", this.item, rolls[0], oldAmmoUpdate);
      if ( oldAmmoUpdate[0] ) {
        ammoUpdate.id = oldAmmoUpdate[0]._id;
        ammoUpdate.quantity = foundry.utils.getProperty(oldAmmoUpdate[0], "system.quantity");
      }
    }

    // Commit ammunition consumption on attack rolls resource consumption if the attack roll was made
    if ( ammoUpdate?.destroy ) {
      // If ammunition was deleted, store a copy of it in the roll message
      const data = this.actor.items.get(ammoUpdate.id).toObject();
      const messageId = messageConfig.data?.flags?.dnd5e?.originatingMessage
        ?? rollConfig.event?.target.closest("[data-message-id]")?.dataset.messageId;
      const attackMessage = dnd5e.registry.messages.get(messageId, "attack")?.pop();
      await attackMessage?.setFlag("dnd5e", "roll.ammunitionData", data);
      await this.actor.deleteEmbeddedDocuments("Item", [ammoUpdate.id]);
    }
    else if ( ammoUpdate ) await this.actor?.updateEmbeddedDocuments("Item", [
      { _id: ammoUpdate.id, "system.quantity": ammoUpdate.quantity }
    ]);

    /**
     * A hook event that fires after an attack has been rolled and ammunition has been consumed.
     * @function dnd5e.postRollAttack
     * @memberof hookEvents
     * @param {D20Roll[]} rolls                   The resulting rolls.
     * @param {object} data
     * @param {AttackActivity|null} data.subject  The activity that performed the attack.
     */
    Hooks.callAll("dnd5e.postRollAttack", rolls, { subject: this });

    return rolls;
  }

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

  /**
   * Configure a roll config for each roll performed as part of the attack process. Will be called once per roll
   * in the process each time an option is changed in the roll configuration interface.
   * @param {D20RollProcessConfiguration} process          Configuration for the entire rolling process.
   * @param {D20RollConfiguration} config                  Configuration for a specific roll.
   * @param {FormDataExtended} [formData]                  Any data entered into the rolling prompt.
   * @param {number} index                                 Index of the roll within all rolls being prepared.
   */
  _buildAttackConfig(process, config, formData, index) {
    const ammunition = formData?.get("ammunition") ?? process.ammunition;
    const attackMode = formData?.get("attackMode") ?? process.attackMode;
    const mastery = formData?.get("mastery") ?? process.mastery;

    let { parts, data } = this.getAttackData({ ammunition, attackMode });
    const options = config.options ?? {};
    if ( ammunition !== undefined ) options.ammunition = ammunition;
    if ( attackMode !== undefined ) options.attackMode = attackMode;
    if ( mastery !== undefined ) options.mastery = mastery;

    config.parts = [...(config.parts ?? []), ...parts];
    config.data = { ...data, ...(config.data ?? {}) };
    config.options = options;
  }

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

  /**
   * Handle performing an attack roll.
   * @this {AttackActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollAttack(event, target, message) {
    this.rollAttack({ event });
  }

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

  /**
   * Handle performing a damage roll.
   * @this {AttackActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollDamage(event, target, message) {
    const lastAttack = message.getAssociatedRolls("attack").pop();
    const attackMode = lastAttack?.getFlag("dnd5e", "roll.attackMode");

    // Fetch the ammunition used with the last attack roll
    let ammunition;
    const actor = lastAttack?.getAssociatedActor();
    if ( actor ) {
      const storedData = lastAttack.getFlag("dnd5e", "roll.ammunitionData");
      ammunition = storedData
        ? new Item.implementation(storedData, { parent: actor })
        : actor.items.get(lastAttack.getFlag("dnd5e", "roll.ammunition"));
    }

    const isCritical = lastAttack?.rolls[0]?.isCritical;
    const dialogConfig = {};
    if ( isCritical ) dialogConfig.options = { defaultButton: "critical" };

    this.rollDamage({ event, ammunition, attackMode, isCritical }, dialogConfig);
  }

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

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), { modifier: this.labels.modifier });
  }
}

/**
 * Sheet for the cast activity.
 */
class CastSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["cast-activity"],
    actions: {
      removeSpell: CastSheet.#removeSpell
    }
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    identity: {
      template: "systems/dnd5e/templates/activity/cast-identity.hbs",
      templates: super.PARTS.identity.templates
    },
    effect: {
      template: "systems/dnd5e/templates/activity/cast-effect.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/cast-spell.hbs",
        "systems/dnd5e/templates/activity/parts/cast-details.hbs"
      ]
    }
  };

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

  /** @inheritDoc */
  async _prepareContext(options) {
    return {
      ...await super._prepareContext(options),
      spell: await fromUuid(this.activity.spell.uuid)
    };
  }

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);

    if ( context.spell ) {
      context.contentLink = context.spell.toAnchor().outerHTML;
      if ( context.spell.system.level > 0 ) context.levelOptions = Object.entries(CONFIG.DND5E.spellLevels)
        .filter(([level]) => Number(level) >= context.spell.system.level)
        .map(([value, label]) => ({ value, label }));
    }

    context.abilityOptions = [
      { value: "", label: game.i18n.localize("DND5E.Spellcasting") },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];
    context.propertyOptions = Array.from(CONFIG.DND5E.validProperties.spell).map(value => ({
      value, label: CONFIG.DND5E.itemProperties[value]?.label ?? ""
    }));

    return context;
  }

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

  /** @inheritDoc */
  async _prepareIdentityContext(context) {
    context = await super._prepareIdentityContext(context);
    if ( context.spell ) context.placeholder = { name: context.spell.name, img: context.spell.img };
    return context;
  }

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

  /** @inheritDoc */
  _getTabs() {
    const tabs = super._getTabs();
    tabs.effect.label = "DND5E.CAST.SECTIONS.Spell";
    tabs.effect.icon = "fa-solid fa-wand-sparkles";
    return tabs;
  }

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

  /**
   * Handle removing the associated spell.
   * @this {CastSheet}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #removeSpell(event, target) {
    this.activity.update({ "spell.uuid": null });
  }
}

const { BooleanField: BooleanField$D, DocumentUUIDField: DocumentUUIDField$4, NumberField: NumberField$F, SchemaField: SchemaField$M, SetField: SetField$v, StringField: StringField$12 } = foundry.data.fields;

/**
 * Data model for a Cast activity.
 *
 * @property {object} spell
 * @property {string} spell.ability              Ability to override default spellcasting ability.
 * @property {object} spell.challenge
 * @property {number} spell.challenge.attack     Flat to hit bonus in place of the spell's normal attack bonus.
 * @property {number} spell.challenge.save       Flat DC to use in place of the spell's normal save DC.
 * @property {boolean} spell.challenge.override  Use custom attack bonus & DC rather than creature's.
 * @property {number} spell.level                Base level at which to cast the spell.
 * @property {Set<string>} spell.properties      Spell components & tags to ignore while casting.
 * @property {boolean} spell.spellbook           Display spell in the Spells tab of the character sheet.
 * @property {string} spell.uuid                 UUID of the spell to cast.
 */
class CastActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    const schema = super.defineSchema();
    delete schema.effects;
    return {
      ...schema,
      spell: new SchemaField$M({
        ability: new StringField$12(),
        challenge: new SchemaField$M({
          attack: new NumberField$F(),
          save: new NumberField$F(),
          override: new BooleanField$D()
        }),
        level: new NumberField$F(),
        properties: new SetField$v(new StringField$12(), { initial: ["vocal", "somatic", "material"] }),
        spellbook: new BooleanField$D({ initial: true }),
        uuid: new DocumentUUIDField$4()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    const spell = fromUuidSync(this.spell.uuid);
    if ( spell ) {
      this.name = this.name || spell.name;
      this.img = this.img || spell.img;
    }
    super.prepareData();
  }

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

  /** @inheritDoc */
  prepareFinalData(rollData) {
    super.prepareFinalData(rollData);

    for ( const field of ["activation", "duration", "range", "target"] ) {
      Object.defineProperty(this[field], "canOverride", {
        value: true,
        configurable: true,
        enumerable: false
      });
    }
  }
}

/**
 * Activity for casting a spell from another item.
 */
class CastActivity extends ActivityMixin(CastActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /**
   * Static ID used for the enchantment that modifies spell data.
   */
  static ENCHANTMENT_ID = staticID("dnd5espellchanges");

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

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.CAST"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "cast",
      img: "systems/dnd5e/icons/svg/activity/cast.svg",
      title: "DND5E.CAST.Title",
      sheetClass: CastSheet
    }, { inplace: false })
  );

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

  /**
   * Cached copy of the associated spell stored on the actor.
   * @type {Item5e|void}
   */
  get cachedSpell() {
    return this.actor?.sourcedItems.get(this.spell.uuid, { legacy: false })
      ?.find(i => i.getFlag("dnd5e", "cachedFor") === this.relativeUUID);
  }

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

  /**
   * Should this spell be listed in the actor's spellbook?
   * @type {boolean}
   */
  get displayInSpellbook() {
    return (this.item.system.magicAvailable !== false) && this.spell.spellbook;
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  async use(usage={}, dialog={}, message={}) {
    if ( !this.item.isEmbedded || this.item.pack ) return;
    if ( !this.item.isOwner ) {
      ui.notifications.error("DND5E.DocumentUseWarn", { localize: true });
      return;
    }

    /**
     * A hook event that fires before a linked spell is used by a Cast activity.
     * @function dnd5e.preUseLinkedSpell
     * @memberof hookEvents
     * @param {CastActivity} activity                                Cast activity being used.
     * @param {Partial<ActivityUseConfiguration>} usageConfig        Configuration info for the activation.
     * @param {Partial<ActivityDialogConfiguration>} dialogConfig    Configuration info for the usage dialog.
     * @param {Partial<ActivityMessageConfiguration>} messageConfig  Configuration info for the created chat message.
     * @returns {boolean}  Explicitly return `false` to prevent activity from being used.
     */
    if ( Hooks.call("dnd5e.preUseLinkedSpell", this, usage, dialog, message) === false ) return;

    let spell = this.cachedSpell;
    if ( !spell ) {
      [spell] = await this.actor.createEmbeddedDocuments("Item", [await this.getCachedSpellData()]);
    }

    const results = await spell.use({ ...usage, legacy: false }, dialog, message);

    /**
     * A hook event that fires after a linked spell is used by a Cast activity.
     * @function dnd5e.postUseLinkedSpell
     * @memberof hookEvents
     * @param {CastActivity} activity                          Activity being activated.
     * @param {Partial<ActivityUseConfiguration>} usageConfig  Configuration data for the activation.
     * @param {ActivityUsageResults} results                   Final details on the activation.
     */
    if ( results ) Hooks.callAll("dnd5e.postUseLinkedSpell", this, usage, results);

    return results;
  }

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

  /**
   * Prepare the data for the cached spell to store on the actor.
   * @returns {Promise<object|void>}
   */
  async getCachedSpellData() {
    const originalSpell = await fromUuid(this.spell.uuid);
    if ( !originalSpell ) return;
    return originalSpell.clone({
      effects: [
        ...originalSpell.effects.map(e => e.toObject()),
        {
          _id: this.constructor.ENCHANTMENT_ID,
          type: "enchantment",
          name: game.i18n.localize("DND5E.CAST.Enchantment.Name"),
          img: "systems/dnd5e/icons/svg/activity/cast.svg",
          origin: this.uuid,
          changes: this.getSpellChanges()
        }
      ],
      flags: {
        dnd5e: {
          cachedFor: this.relativeUUID
        }
      },
      _stats: { compendiumSource: this.spell.uuid }
    }).toObject();
  }

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

  /**
   * Create spell changes based on the activity's configuration.
   * @returns {object[]}
   */
  getSpellChanges() {
    const changes = [];
    const source = this.toObject();

    // Override spell details
    for ( const type of ["activation", "duration", "range", "target"] ) {
      if ( !this[type].override ) continue;
      const data = source[type];
      delete data.override;
      changes.push({ key: `system.${type}`, mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: JSON.stringify(data) });
    }

    // Set the casting ability
    if ( this.spell.ability ) changes.push({
      key: "system.ability", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: this.spell.ability
    });

    // Remove ignored properties
    for ( const property of this.spell.properties ) {
      changes.push({ key: "system.properties", mode: CONST.ACTIVE_EFFECT_MODES.ADD, value: `-${property}` });
    }

    // Set challenge overrides
    const challenge = this.spell.challenge;
    if ( challenge.override && challenge.attack ) changes.push(
      { key: "activities[attack].attack.bonus", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: challenge.attack },
      { key: "activities[attack].attack.flat", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: true }
    );
    if ( challenge.override && challenge.save ) changes.push(
      { key: "activities[save].save.dc.calculation", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: "" },
      { key: "activities[save].save.dc.formula", mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE, value: challenge.save }
    );

    return changes;
  }
}

/**
 * Object representing a nested set of choices to be displayed in a grouped select list or a trait selector.
 *
 * @typedef {object} SelectChoicesEntry
 * @property {string} label              Label, either pre- or post-localized.
 * @property {boolean} [chosen]          Has this choice been selected?
 * @property {boolean} [sorting=true]    Should this value be sorted? If there are a mixture of this value at
 *                                       a level, unsorted values are listed first followed by sorted values.
 * @property {SelectChoices} [children]  Nested choices. If wildcard filtering support is desired, then trait keys
 *                                       should be provided prefixed for children (e.g. `parent:child`, rather than
 *                                       just `child`).
 */

/**
 * Object with a number of methods for performing actions on a nested set of choices.
 *
 * @param {Object<string, SelectChoicesEntry>} [choices={}]  Initial choices for the object.
 */
class SelectChoices {
  constructor(choices={}) {
    const clone = foundry.utils.deepClone(choices);
    for ( const value of Object.values(clone) ) {
      if ( !value.children || (value.children instanceof SelectChoices) ) continue;
      value.category = true;
      value.children = new this.constructor(value.children);
    }
    Object.assign(this, clone);
  }

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

  /**
   * Create a set of available choice keys.
   * @param {Set<string>} [set]  Existing set to which the values will be added.
   * @returns {Set<string>}
   */
  asSet(set) {
    set ??= new Set();
    for ( const [key, choice] of Object.entries(this) ) {
      if ( choice.children ) choice.children.asSet(set);
      else set.add(key);
    }
    return set;
  }

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

  /**
   * Create a clone of this object.
   * @returns {SelectChoices}
   */
  clone() {
    const newData = {};
    for ( const [key, value] of Object.entries(this) ) {
      newData[key] = foundry.utils.deepClone(value);
      if ( value.children ) newData[key].children = value.children.clone();
    }
    const clone = new this.constructor(newData);
    return clone;
  }

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

  /**
   * Find key and value for the provided key or key suffix.
   * @param {string} key  Full prefixed key (e.g. `tool:art:alchemist`) or just the suffix (e.g. `alchemist`).
   * @returns {[string, SelectChoicesEntry]|null}  An array with the first value being the matched key,
   *                                               and the second being the value.
   */
  find(key) {
    for ( const [k, v] of Object.entries(this) ) {
      if ( (k === key) || k.endsWith(`:${key}`) ) {
        return [k, v];
      } else if ( v.children ) {
        const result = v.children.find(key);
        if ( result ) return result;
      }
    }
    return null;
  }

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

  /**
   * Execute the provided function for each entry in the object.
   * @param {Function} func  Function to execute on each entry. Receives the trait key and value.
   */
  forEach(func) {
    for ( const [key, value] of Object.entries(this) ) {
      func(key, value);
      if ( value.children ) value.children.forEach(func);
    }
  }

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

  /**
   * Merge another SelectChoices object into this one.
   * @param {SelectChoices} other
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]  Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}
   */
  merge(other, { inplace=true }={}) {
    if ( !inplace ) return this.clone().merge(other);
    return foundry.utils.mergeObject(this, other);
  }

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

  /**
   * Internal sorting method.
   * @param {object} lhs
   * @param {object} rhs
   * @returns {number}
   * @protected
   */
  _sort(lhs, rhs) {
    if ( (lhs.sorting === false) && (rhs.sorting === false) ) return 0;
    if ( lhs.sorting === false ) return -1;
    if ( rhs.sorting === false ) return 1;
    return lhs.label.localeCompare(rhs.label, game.i18n.lang);
  }

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

  /**
   * Sort the entries using the label.
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]  Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}
   */
  sort({ inplace=true }={}) {
    const sorted = new SelectChoices(sortObjectEntries(this, this._sort));

    if ( inplace ) {
      for ( const key of Object.keys(this) ) delete this[key];
      this.merge(sorted);
      for ( const entry of Object.values(this) ) {
        if ( entry.children ) entry.children.sort();
      }
      return this;
    }

    else {
      for ( const entry of Object.values(sorted) ) {
        if ( entry.children ) entry.children = entry.children.sort({ inplace });
      }
      return sorted;
    }
  }

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

  /**
   * Filters choices in place to only include the provided keys.
   * @param {Set<string>|SelectChoices} filter   Keys of traits to retain or another SelectChoices object.
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]     Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}                    This SelectChoices with filter applied.
   *
   * @example
   * const choices = new SelectChoices({
   *   categoryOne: { label: "One" },
   *   categoryTwo: { label: "Two", children: {
   *     childOne: { label: "Child One" },
   *     childTwo: { label: "Child Two" }
   *   } }
   * });
   *
   * // Results in only categoryOne
   * choices.filter(new Set(["categoryOne"]));
   *
   * // Results in only categoryTwo, but none if its children
   * choices.filter(new Set(["categoryTwo"]));
   *
   * // Results in categoryTwo and all of its children
   * choices.filter(new Set(["categoryTwo:*"]));
   *
   * // Results in categoryTwo with only childOne
   * choices.filter(new Set(["categoryTwo:childOne"]));
   *
   * // Results in categoryOne, plus categoryTwo with only childOne
   * choices.filter(new Set(["categoryOne", "categoryTwo:childOne"]));
   *
   * @example
   * const choices = new SelectChoices({
   *   "type:categoryOne": { label: "One" },
   *   "type:categoryTwo": { label: "Two", children: {
   *     "type:categoryOne:childOne": { label: "Child One" },
   *     "type:categoryOne:childTwo": { label: "Child Two" }
   *   } }
   * });
   *
   * // Results in no changes
   * choices.filter(new Set(["type:*"]));
   *
   * // Results in only categoryOne
   * choices.filter(new Set(["type:categoryOne"]));
   *
   * // Results in categoryTwo and all of its children
   * choices.filter(new Set(["type:categoryTwo:*"]));
   *
   * // Results in categoryTwo with only childOne
   * choices.filter(new Set(["type:categoryTwo:childOne"]));
   */
  filter(filter, { inplace=true }={}) {
    if ( !inplace ) return this.clone().filter(filter);
    if ( filter instanceof SelectChoices ) filter = filter.asSet();

    for ( const [key, trait] of Object.entries(this) ) {
      // Remove children if direct match and no wildcard for this category present
      const wildcardKey = key.replace(/(:|^)(\w+)$/, "$1*");
      if ( filter.has(key) && !filter.has(wildcardKey) ) {
        if ( trait.children ) delete trait.children;
      }

      // Check children, remove entry if no children match filter
      else if ( !filter.has(wildcardKey) && !filter.has(`${key}:*`) ) {
        if ( trait.children ) trait.children.filter(filter);
        if ( !Object.keys(trait.children ?? {}).length ) delete this[key];
      }
    }

    return this;
  }

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

  /**
   * Removes in place any traits or categories the keys of which are included in the exclusion set.
   * Note: Wildcard keys are not supported with this method.
   * @param {Set<string>} keys                Set of keys to remove from the choices.
   * @param {object} [options={}]
   * @param {boolean} [options.inplace=true]  Should this SelectChoices be mutated or a new one returned?
   * @returns {SelectChoices}                 This SelectChoices with excluded keys removed.
   *
   * @example
   * const choices = new SelectChoices({
   *   categoryOne: { label: "One" },
   *   categoryTwo: { label: "Two", children: {
   *     childOne: { label: "Child One" },
   *     childTwo: { label: "Child Two" }
   *   } }
   * });
   *
   * // Results in categoryOne being removed
   * choices.exclude(new Set(["categoryOne"]));
   *
   * // Results in categoryOne and childOne being removed, but categoryTwo and childTwo remaining
   * choices.exclude(new Set(["categoryOne", "categoryTwo:childOne"]));
   */
  exclude(keys, { inplace=true }={}) {
    if ( !inplace ) return this.clone().exclude(keys);
    for ( const [key, trait] of Object.entries(this) ) {
      if ( keys.has(key) ) delete this[key];
      else if ( trait.children ) trait.children = trait.children.exclude(keys);
    }
    return this;
  }
}

/**
 * Cached version of the base items compendia indices with the needed subtype fields.
 * @type {object}
 * @private
 */
const _cachedIndices = {};

/**
 * Determine the appropriate label to use for a trait category.
 * @param {object|string} data  Category for which to fetch the label.
 * @param {object} config       Trait configuration data.
 * @returns {string}
 * @private
 */
function _innerLabel(data, config) {
  return foundry.utils.getType(data) === "Object"
    ? foundry.utils.getProperty(data, config.labelKeyPath ?? "label") : data;
}

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

/**
 * Get the schema fields for this trait on the actor.
 * @param {Actor5e} actor  Actor for which to get the fields.
 * @param {string} trait   Trait as defined in `CONFIG.DND5E.traits`.
 * @returns {object|void}
 */
function actorFields(actor, trait) {
  const keyPath = actorKeyPath(trait);
  return (keyPath.startsWith("system.")
    ? actor.system.schema.getField(keyPath.slice(7))
    : actor.schema.getField(keyPath))?.fields;
}

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

/**
 * Get the key path to the specified trait on an actor.
 * @param {string} trait  Trait as defined in `CONFIG.DND5E.traits`.
 * @returns {string}      Key path to this trait's object within an actor's system data.
 */
function actorKeyPath(trait) {
  const traitConfig = CONFIG.DND5E.traits[trait];
  if ( traitConfig.actorKeyPath ) return traitConfig.actorKeyPath;
  return `system.traits.${trait}`;
}

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

/**
 * Get the current trait values for the provided actor.
 * @param {Actor5e} actor  Actor from which to retrieve the values.
 * @param {string} trait   Trait as defined in `CONFIG.DND5E.traits`.
 * @returns {Object<number>}
 */
async function actorValues(actor, trait) {
  const keyPath = actorKeyPath(trait);
  const data = foundry.utils.getProperty(actor, keyPath);
  if ( !data ) return {};
  const values = {};
  const traitChoices = await choices(trait, {prefixed: true});

  const setValue = (k, v) => {
    const result = traitChoices.find(k);
    if ( result ) values[result[0]] = v;
  };

  if ( ["skills", "tool"].includes(trait) ) {
    Object.entries(data).forEach(([k, d]) => setValue(k, d.value));
  } else if ( trait === "saves" ) {
    Object.entries(data).forEach(([k, d]) => setValue(k, d.proficient));
  } else if ( trait === "dm" ) {
    Object.entries(data.amount).forEach(([k, d]) => setValue(k, d));
  } else {
    data.value?.forEach(v => setValue(v, 1));
  }

  if ( trait === "weapon" ) data.mastery?.value?.forEach(v => setValue(v, 2));

  return values;
}

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

/**
 * Calculate the change key path for a provided trait key.
 * @param {string} key      Key for a trait to set.
 * @param {string} [trait]  Trait as defined in `CONFIG.DND5E.traits`, only needed if key isn't prefixed.
 * @returns {string|void}
 */
function changeKeyPath(key, trait) {
  const split = key.split(":");
  if ( !trait ) trait = split.shift();

  const traitConfig = CONFIG.DND5E.traits[trait];
  if ( !traitConfig ) return;

  let keyPath = actorKeyPath(trait);

  if ( trait === "saves" ) {
    return `${keyPath}.${split.pop()}.proficient`;
  } else if ( ["skills", "tool"].includes(trait) ) {
    return `${keyPath}.${split.pop()}.value`;
  } else {
    return `${keyPath}.value`;
  }
}

/* -------------------------------------------- */
/*  Trait Lists                                 */
/* -------------------------------------------- */

/**
 * Build up a trait structure containing all of the children gathered from config & base items.
 * @param {string} trait       Trait as defined in `CONFIG.DND5E.traits`.
 * @returns {Promise<object>}  Object with trait categories and children.
 */
async function categories(trait) {
  const traitConfig = CONFIG.DND5E.traits[trait];
  const config = foundry.utils.deepClone(CONFIG.DND5E[traitConfig.configKey ?? trait]);

  for ( const key of Object.keys(config) ) {
    if ( foundry.utils.getType(config[key]) !== "Object" ) config[key] = { label: config[key] };
    if ( traitConfig.children?.[key] ) {
      const children = config[key].children ??= {};
      for ( const [childKey, value] of Object.entries(CONFIG.DND5E[traitConfig.children[key]]) ) {
        if ( foundry.utils.getType(value) !== "Object" ) children[childKey] = { label: value };
        else children[childKey] = { ...value };
      }
    }
  }

  if ( traitConfig.subtypes ) {
    const map = CONFIG.DND5E[`${trait}ProficienciesMap`];

    // Merge all ID lists together
    const ids = traitConfig.subtypes.ids.reduce((obj, key) => {
      foundry.utils.mergeObject(obj, CONFIG.DND5E[key] ?? {});
      return obj;
    }, {});

    // Fetch base items for all IDs
    const baseItems = await Promise.all(Object.entries(ids).map(async ([key, id]) => {
      const index = await getBaseItem(id);
      return [key, index];
    }));

    // Sort base items as children of categories based on subtypes
    for ( const [key, index] of baseItems ) {
      if ( !index ) continue;

      // Get the proper subtype, using proficiency map if needed
      let type = index.system.type.value;
      if ( map?.[type] ) type = map[type];

      // No category for this type, add at top level
      if ( !config[type] ) config[key] = { label: index.name };

      // Add as child of appropriate category
      else {
        config[type].children ??= {};
        config[type].children[key] = { label: index.name };
      }
    }
  }

  return config;
}

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

/**
 * Get a list of choices for a specific trait.
 * @param {string} trait                      Trait as defined in `CONFIG.DND5E.traits`.
 * @param {object} [options={}]
 * @param {Set<string>} [options.chosen=[]]   Optional list of keys to be marked as chosen.
 * @param {boolean} [options.prefixed=false]  Should keys be prefixed with trait type?
 * @param {boolean} [options.any=false]       Should the "Any" option be added to each category?
 * @returns {Promise<SelectChoices>}          Object mapping proficiency ids to choice objects.
 */
async function choices(trait, { chosen=new Set(), prefixed=false, any=false }={}) {
  const traitConfig = CONFIG.DND5E.traits[trait];
  if ( !traitConfig ) return new SelectChoices();
  if ( foundry.utils.getType(chosen) === "Array" ) chosen = new Set(chosen);
  const categoryData = await categories(trait);

  let result = {};

  if ( traitConfig.labels?.all && !any ) {
    const key = prefixed ? `${trait}:ALL` : "ALL";
    result[key] = { label: traitConfig.labels.all, chosen: chosen.has(key), sorting: false };
  }

  if ( prefixed && any ) {
    const key = `${trait}:*`;
    result[key] = {
      label: keyLabel(key).titleCase(),
      chosen: chosen.has(key), sorting: false, wildcard: true
    };
  }

  const prepareCategory = (key, data, result, prefix, topLevel=false) => {
    let label = _innerLabel(data, traitConfig);
    if ( !label ) label = key;
    if ( prefixed ) key = `${prefix}:${key}`;
    result[key] = {
      label: game.i18n.localize(label),
      chosen: data.selectable !== false ? chosen.has(key) : false,
      selectable: data.selectable !== false,
      sorting: topLevel ? traitConfig.sortCategories === true : true
    };
    if ( data.children ) {
      const children = result[key].children = {};
      if ( prefixed && any ) {
        const anyKey = `${key}:*`;
        children[anyKey] = {
          label: keyLabel(anyKey).titleCase(),
          chosen: chosen.has(anyKey), sorting: false, wildcard: true
        };
      }
      Object.entries(data.children).forEach(([k, v]) => prepareCategory(k, v, children, key));
    }
  };

  Object.entries(categoryData).forEach(([k, v]) => prepareCategory(k, v, result, trait, true));

  return new SelectChoices(result).sort();
}

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

/**
 * Prepare an object with all possible choices from a set of keys. These choices will be grouped by
 * trait type if more than one type is present.
 * @param {Set<string>} keys  Prefixed trait keys.
 * @returns {Promise<SelectChoices>}
 */
async function mixedChoices(keys) {
  if ( !keys.size ) return new SelectChoices();
  const types = {};
  for ( const key of keys ) {
    const split = key.split(":");
    const trait = split.shift();
    const selectChoices = (await choices(trait, { prefixed: true })).filter(new Set([key]));
    types[trait] ??= { label: traitLabel(trait), children: new SelectChoices() };
    types[trait].children.merge(selectChoices);
  }
  if ( Object.keys(types).length > 1 ) return new SelectChoices(types);
  return Object.values(types)[0].children;
}

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

/**
 * Fetch an item for the provided ID. If the provided ID contains a compendium pack name
 * it will be fetched from that pack, otherwise it will be fetched from the compendium defined
 * in `DND5E.sourcePacks.ITEMS`.
 * @param {string} identifier            Simple ID or compendium name and ID separated by a dot.
 * @param {object} [options]
 * @param {boolean} [options.indexOnly]  If set to true, only the index data will be fetched (will never return
 *                                       Promise).
 * @param {boolean} [options.fullItem]   If set to true, the full item will be returned as long as `indexOnly` is
 *                                       false.
 * @returns {Promise<Item5e>|object}     Promise for a `Document` if `indexOnly` is false & `fullItem` is true,
 *                                       otherwise else a simple object containing the minimal index data.
 */
function getBaseItem(identifier, { indexOnly=false, fullItem=false }={}) {
  const uuid = getBaseItemUUID(identifier);
  const { collection, documentId: id } = foundry.utils.parseUuid(uuid);
  const pack = collection?.metadata.id;

  // Full Item5e document required, always async.
  if ( fullItem && !indexOnly ) return collection?.getDocument(id);

  const cache = _cachedIndices[pack];
  const loading = cache instanceof Promise;

  // Return extended index if cached, otherwise normal index, guaranteed to never be async.
  if ( indexOnly ) {
    const index = collection?.index.get(id);
    return loading ? index : cache?.[id] ?? index;
  }

  // Returned cached version of extended index if available.
  if ( loading ) return cache.then(() => _cachedIndices[pack][id]);
  else if ( cache ) return cache[id];
  if ( !collection ) return;

  // Build the extended index and return a promise for the data
  const fields = traitIndexFields();
  const promise = collection.getIndex({ fields }).then(index => {
    const store = index.reduce((obj, entry) => {
      obj[entry._id] = entry;
      return obj;
    }, {});
    _cachedIndices[pack] = store;
    return store[id];
  });
  _cachedIndices[pack] = promise;
  return promise;
}

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

/**
 * Construct a proper UUID for the provided base item ID.
 * @param {string} identifier  Simple ID, compendium name and ID separated by a dot, or proper UUID.
 * @returns {string}
 */
function getBaseItemUUID(identifier) {
  if ( identifier.startsWith("Compendium.") ) return identifier;
  let pack = CONFIG.DND5E.sourcePacks.ITEMS;
  let [scope, collection, id] = identifier.split(".");
  if ( scope && collection ) pack = `${scope}.${collection}`;
  if ( !id ) id = identifier;
  return `Compendium.${pack}.Item.${id}`;
}

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

/**
 * List of fields on items that should be indexed for retrieving subtypes.
 * @returns {string[]}  Index list to pass to `Compendium#getIndex`.
 * @protected
 */
function traitIndexFields() {
  const fields = ["system.type.value"];
  for ( const traitConfig of Object.values(CONFIG.DND5E.traits) ) {
    if ( !traitConfig.subtypes ) continue;
    fields.push(`system.${traitConfig.subtypes.keyPath}`);
  }
  return fields;
}

/* -------------------------------------------- */
/*  Localized Formatting Methods                */
/* -------------------------------------------- */

/**
 * Get the localized label for a specific trait type.
 * @param {string} trait    Trait as defined in `CONFIG.DND5E.traits`.
 * @param {number} [count]  Count used to determine pluralization. If no count is provided, will default to
 *                          the 'other' pluralization.
 * @returns {string}        Localized label.
 */
function traitLabel(trait, count) {
  const traitConfig = CONFIG.DND5E.traits[trait];
  const pluralRule = (count !== undefined) ? new Intl.PluralRules(game.i18n.lang).select(count) : "other";
  if ( !traitConfig ) return game.i18n.localize(`DND5E.TraitGenericPlural.${pluralRule}`);
  return game.i18n.localize(`${traitConfig.labels.localization}.${pluralRule}`);
}

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

/**
 * Retrieve the proper display label for the provided key. Will return a promise unless a categories
 * object is provided in config.
 * @param {string} key              Key for which to generate the label.
 * @param {object} [config={}]
 * @param {number} [config.count]   Number to display, only if a wildcard is used as final part of key.
 * @param {string} [config.trait]   Trait as defined in `CONFIG.DND5E.traits` if not using a prefixed key.
 * @param {boolean} [config.final]  Is this the final in a list?
 * @returns {string}                Retrieved label.
 *
 * @example
 * // Returns "Tool Proficiency"
 * keyLabel("tool");
 *
 * @example
 * // Returns "Artisan's Tools"
 * keyLabel("tool:art");
 *
 * @example
 * // Returns "any Artisan's Tools"
 * keyLabel("tool:art:*");
 *
 * @example
 * // Returns "any 2 Artisan's Tools"
 * keyLabel("tool:art:*", { count: 2 });
 *
 * @example
 * // Returns "2 other Artisan's Tools"
 * keyLabel("tool:art:*", { count: 2, final: true });
 *
 * @example
 * // Returns "Gaming Sets"
 * keyLabel("tool:game");
 *
 * @example
 * // Returns "Land Vehicle"
 * keyLabel("tool:vehicle:land");
 *
 * @example
 * // Returns "Shortsword"
 * keyLabel("weapon:shortsword");
 * keyLabel("weapon:simple:shortsword");
 * keyLabel("shortsword", { trait: "weapon" });
 */
function keyLabel(key, config={}) {
  let { count, trait, final } = config;

  let parts = key.split(":");
  const pluralRules = new Intl.PluralRules(game.i18n.lang);

  if ( !trait ) trait = parts.shift();
  const traitConfig = CONFIG.DND5E.traits[trait];
  if ( !traitConfig ) return key;
  const traitData = CONFIG.DND5E[traitConfig.configKey ?? trait] ?? {};
  let categoryLabel = game.i18n.localize(`${traitConfig.labels.localization}.${
    pluralRules.select(count ?? 1)}`);

  // Trait (e.g. "Tool Proficiency")
  const lastKey = parts.pop();
  if ( !lastKey ) return categoryLabel;

  // All (e.g. "All Languages")
  if ( lastKey === "ALL" ) return traitConfig.labels?.all ?? key;

  // Wildcards (e.g. "Artisan's Tools", "any Artisan's Tools", "any 2 Artisan's Tools", or "2 other Artisan's Tools")
  else if ( lastKey === "*" ) {
    let type;
    if ( parts.length ) {
      let category = traitData;
      do {
        category = (category.children ?? category)[parts.shift()];
        if ( !category ) return key;
      } while ( parts.length );
      type = _innerLabel(category, traitConfig);
    } else type = categoryLabel.toLowerCase();
    const localization = `DND5E.TraitConfigChoose${final ? "Other" : `Any${count ? "Counted" : "Uncounted"}`}`;
    return game.i18n.format(localization, { count: count ?? 1, type });
  }

  else {
    // Category (e.g. "Gaming Sets")
    const category = traitData[lastKey];
    if ( category ) return _innerLabel(category, traitConfig);

    // Child (e.g. "Land Vehicle")
    for ( const childrenKey of Object.values(traitConfig.children ?? {}) ) {
      const childLabel = CONFIG.DND5E[childrenKey]?.[lastKey];
      if ( childLabel ) return childLabel;
    }

    // Base item (e.g. "Shortsword")
    for ( const idsKey of traitConfig.subtypes?.ids ?? [] ) {
      const baseItemId = CONFIG.DND5E[idsKey]?.[lastKey];
      if ( !baseItemId ) continue;
      const index = getBaseItem(baseItemId, { indexOnly: true });
      if ( index ) return index.name;
      break;
    }

    // Explicit categories (e.g. languages)
    const searchCategory = (data, key) => {
      for ( const [k, v] of Object.entries(data) ) {
        if ( k === key ) return v;
        if ( v.children ) {
          const result = searchCategory(v.children, key);
          if ( result ) return result;
        }
      }
    };
    const config = searchCategory(traitData, lastKey);
    return config ? _innerLabel(config, traitConfig) : key;
  }
}

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

/**
 * Create a human readable description of the provided choice.
 * @param {TraitChoice} choice             Data for a specific choice.
 * @param {object} [options={}]
 * @param {boolean} [options.only=false]   Is this choice on its own, or part of a larger list?
 * @param {boolean} [options.final=false]  If this choice is part of a list of other grants or choices,
 *                                         is it in the final position?
 * @returns {string}
 *
 * @example
 * // Returns "any three skill proficiencies"
 * choiceLabel({ count: 3, pool: new Set(["skills:*"]) });
 *
 * @example
 * // Returns "three other skill proficiencies"
 * choiceLabel({ count: 3, pool: new Set(["skills:*"]) }, { final: true });
 *
 * @example
 * // Returns "any skill proficiency"
 * choiceLabel({ count: 1, pool: new Set(["skills:*"]) }, { only: true });
 *
 * @example
 * // Returns "Thieves Tools or any skill"
 * choiceLabel({ count: 1, pool: new Set(["tool:thief", "skills:*"]) }, { only: true });
 *
 * @example
 * // Returns "Thieves' Tools or any artisan tool"
 * choiceLabel({ count: 1, pool: new Set(["tool:thief", "tool:art:*"]) }, { only: true });
 *
 * @example
 * // Returns "2 from Thieves' Tools or any skill proficiency"
 * choiceLabel({ count: 2, pool: new Set(["tool:thief", "skills:*"]) });
 *
 */
function choiceLabel(choice, { only=false, final=false }={}) {
  if ( !choice.pool.size ) return "";

  // Single entry in pool (e.g. "any three skill proficiencies" or "three other skill proficiencies")
  if ( choice.pool.size === 1 ) {
    return keyLabel(choice.pool.first(), {
      count: (choice.count > 1 || !only) ? choice.count : null, final: final && !only
    });
  }

  const listFormatter = new Intl.ListFormat(game.i18n.lang, { type: "disjunction" });

  // Singular count (e.g. "any skill", "Thieves Tools or any skill", or "Thieves' Tools or any artisan tool")
  if ( (choice.count === 1) && only ) {
    return listFormatter.format(choice.pool.map(key => keyLabel(key)));
  }

  // Select from a list of options (e.g. "2 from Thieves' Tools or any skill proficiency")
  const choices = choice.pool.map(key => keyLabel(key));
  return game.i18n.format("DND5E.TraitConfigChooseList", {
    count: choice.count,
    list: listFormatter.format(choices)
  });
}

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

/**
 * Create a human readable description of trait grants & choices.
 * @param {object} config
 * @param {Set<string>} [config.grants]        Guaranteed trait grants.
 * @param {TraitChoice[]} [config.choices=[]]  Trait choices.
 * @returns {string}
 *
 * @example
 * // Returns "Acrobatics and Athletics"
 * localizedList({ grants: new Set(["skills:acr", "skills:ath"]) });
 *
 * @example
 * // Returns "Acrobatics and one other skill proficiency"
 * localizedList({ grants: new Set(["skills:acr"]), choices: [{ count: 1, pool: new Set(["skills:*"])}] });
 *
 * @example
 * // Returns "Choose any skill proficiency"
 * localizedList({ choices: [{ count: 1, pool: new Set(["skills:*"])}] });
 */
function localizedList({ grants=new Set(), choices=[] }) {
  const sections = Array.from(grants).map(g => keyLabel(g));

  for ( const [index, choice] of choices.entries() ) {
    const final = index === choices.length - 1;
    sections.push(choiceLabel(choice, { final, only: !grants.size && choices.length === 1 }));
  }

  const listFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
  if ( !sections.length || grants.size ) return listFormatter.format(sections);
  return game.i18n.format("DND5E.TraitConfigChooseWrapper", {
    choices: listFormatter.format(sections)
  });
}

var trait = /*#__PURE__*/Object.freeze({
  __proto__: null,
  actorFields: actorFields,
  actorKeyPath: actorKeyPath,
  actorValues: actorValues,
  categories: categories,
  changeKeyPath: changeKeyPath,
  choiceLabel: choiceLabel,
  choices: choices,
  getBaseItem: getBaseItem,
  getBaseItemUUID: getBaseItemUUID,
  keyLabel: keyLabel,
  localizedList: localizedList,
  mixedChoices: mixedChoices,
  traitIndexFields: traitIndexFields,
  traitLabel: traitLabel
});

/**
 * Sheet for the check activity.
 */
class CheckSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["check-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/check-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/check-details.hbs"
      ]
    }
  };

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);

    const group = game.i18n.localize("DND5E.Abilities");
    context.abilityOptions = [
      { value: "", label: "" },
      { rule: true },
      { value: "spellcasting", label: game.i18n.localize("DND5E.SpellAbility") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({ value, label: config.label, group }))
    ];
    let ability;
    const associated = this.activity.check.associated;
    if ( (this.item.type === "tool") && !associated.size ) {
      ability = CONFIG.DND5E.abilities[this.item.system.ability]?.label?.toLowerCase();
    } else if ( (associated.size === 1) && (associated.first() in CONFIG.DND5E.skills) ) {
      ability = CONFIG.DND5E.abilities[CONFIG.DND5E.skills[associated.first()].ability]?.label?.toLowerCase();
    }
    if ( ability ) context.abilityOptions[0].label = game.i18n.format("DND5E.DefaultSpecific", { default: ability });

    context.associatedOptions = [
      ...Object.entries(CONFIG.DND5E.skills).map(([value, { label }]) => ({
        value, label, group: game.i18n.localize("DND5E.Skills")
      })),
      ...Object.keys(CONFIG.DND5E.toolIds).map(value => ({
        value, label: keyLabel(value, { trait: "tool" }), group: game.i18n.localize("TYPES.Item.toolPl")
      })).sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang))
    ];

    context.calculationOptions = [
      { value: "", label: game.i18n.localize("DND5E.SAVE.FIELDS.save.dc.CustomFormula") },
      { rule: true },
      { value: "spellcasting", label: game.i18n.localize("DND5E.SpellAbility") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({ value, label: config.label, group }))
    ];

    return context;
  }
}

const { SchemaField: SchemaField$L, SetField: SetField$u, StringField: StringField$11 } = foundry.data.fields;

/**
 * Data model for a check activity.
 *
 * @property {object} check
 * @property {string} check.ability          Ability used with the check.
 * @property {Set<string>} check.associated  Skills or tools that can contribute to the check.
 * @property {object} check.dc
 * @property {string} check.dc.calculation   Method or ability used to calculate the difficulty class of the check.
 * @property {string} check.dc.formula       Custom DC formula or flat value.
 */
class CheckActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      check: new SchemaField$L({
        ability: new StringField$11(),
        associated: new SetField$u(new StringField$11()),
        dc: new SchemaField$L({
          calculation: new StringField$11(),
          formula: new FormulaField({ deterministic: true })
        })
      })
    };
  }

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

  /** @override */
  get ability() {
    if ( this.check.dc.calculation in CONFIG.DND5E.abilities ) return this.check.dc.calculation;
    if ( this.check.dc.calculation === "spellcasting" ) return this.spellcastingAbility;
    return this.check.ability;
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      check: {
        ability: source.system.ability ?? Object.keys(CONFIG.DND5E.abilities)[0]
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);

    if ( this.check.ability === "spellcasting" ) this.check.ability = this.spellcastingAbility;

    let ability;
    if ( this.check.dc.calculation ) ability = this.ability;
    else this.check.dc.value = simplifyBonus(this.check.dc.formula, rollData);
    if ( ability ) this.check.dc.value = this.actor?.system.abilities?.[ability]?.dc
      ?? 8 + (this.actor?.system.attributes?.prof ?? 0);

    if ( !this.check.dc.value ) this.check.dc.value = null;
  }

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

  /**
   * Get the ability to use with an associated value.
   * @param {string} associated  Skill or tool ID.
   * @returns {string|null}      Ability to use.
   */
  getAbility(associated) {
    if ( this.check.ability ) return this.check.ability;
    if ( associated in CONFIG.DND5E.skills ) return CONFIG.DND5E.skills[associated]?.ability ?? null;
    else if ( associated in CONFIG.DND5E.tools ) {
      if ( (this.item.type === "tool") && this.item.system.ability ) return this.item.system.ability;
      return CONFIG.DND5E.tools[associated]?.ability ?? null;
    }
    return null;
  }
}

/**
 * Activity for making ability checks.
 */
class CheckActivity extends ActivityMixin(CheckActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.CHECK"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "check",
      img: "systems/dnd5e/icons/svg/activity/check.svg",
      title: "DND5E.CHECK.Title",
      sheetClass: CheckSheet,
      usage: {
        actions: {
          rollCheck: CheckActivity.#rollCheck
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    const buttons = [];
    const dc = this.check.dc.value;

    const createButton = (abilityKey, associated) => {
      const ability = CONFIG.DND5E.abilities[abilityKey]?.label;
      const checkType = (associated in CONFIG.DND5E.skills) ? "skill"
        : (associated in CONFIG.DND5E.toolIds) ? "tool": "ability";
      const dataset = { ability: abilityKey, action: "rollCheck", visibility: "all" };
      if ( dc ) dataset.dc = dc;
      if ( checkType !== "ability" ) dataset[checkType] = associated;

      let label = ability;
      let type;
      if ( checkType === "skill" ) type = CONFIG.DND5E.skills[associated]?.label;
      else if ( checkType === "tool" ) type = keyLabel(associated, { trait: "tool" });
      if ( type ) label = game.i18n.format("EDITOR.DND5E.Inline.SpecificCheck", { ability, type });
      else label = ability;

      buttons.push({
        label: dc ? `
          <span class="visible-dc">${game.i18n.format("EDITOR.DND5E.Inline.DC", { dc, check: wrap(label) })}</span>
          <span class="hidden-dc">${wrap(label)}</span>
        ` : wrap(label),
        icon: checkType === "tool" ? '<i class="fa-solid fa-hammer" inert></i>'
          : '<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/ability-score-improvement.svg" inert></i>',
        dataset
      });
    };
    const wrap = check => game.i18n.format("EDITOR.DND5E.Inline.CheckShort", { check });

    const associated = Array.from(this.check.associated);
    if ( !associated.length && (this.item.type === "tool") ) associated.push(this.item.system.type.baseItem);
    if ( associated.length ) associated.forEach(a => {
      const ability = this.getAbility(a);
      if ( ability ) createButton(ability, a);
    });
    else if ( this.check.ability ) createButton(this.check.ability);

    return buttons.concat(super._usageChatButtons(message));
  }

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

  /**
   * Handle performing an ability check.
   * @this {CheckActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static async #rollCheck(event, target, message) {
    const targets = getSceneTargets();
    if ( !targets.length && game.user.character ) targets.push(game.user.character);
    if ( !targets.length ) ui.notifications.warn("DND5E.ActionWarningNoToken", { localize: true });
    let { ability, dc, skill, tool } = target.dataset;
    dc = parseInt(dc);
    const rollData = { event, target: Number.isFinite(dc) ? dc : this.check.dc.value };
    if ( ability in CONFIG.DND5E.abilities ) rollData.ability = ability;

    for ( const token of targets ) {
      const actor = token instanceof Actor ? token : token.actor;
      const speaker = ChatMessage.getSpeaker({ actor, scene: canvas.scene, token: token.document });
      const messageData = { data: { speaker } };
      if ( skill ) await actor.rollSkill({ ...rollData, skill }, {}, messageData);
      else if ( tool ) {
        rollData.tool = tool;
        if ( (this.item.type === "tool") && !this.check.associated.size ) {
          rollData.bonus = this.item.system.bonus;
          rollData.prof = this.item.system.prof;
          rollData.item = this.item;
        }
        await actor.rollToolCheck(rollData, {}, messageData);
      }
      else await actor.rollAbilityCheck(rollData, {}, messageData);
    }
  }
}

/**
 * Sheet for the damage activity.
 */
class DamageSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["damage-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/damage-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/damage-damage.hbs",
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/damage-parts.hbs"
      ]
    }
  };
}

const { ArrayField: ArrayField$l, BooleanField: BooleanField$C, SchemaField: SchemaField$K } = foundry.data.fields;

/**
 * Data model for an damage activity.
 *
 * @property {object} damage
 * @property {boolean} damage.critical.allow  Can this damage be critical?
 * @property {string} damage.critical.bonus   Extra damage applied to the first damage part when a critical is rolled.
 * @property {DamageData[]} damage.parts      Parts of damage to inflict.
 */
class DamageActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      damage: new SchemaField$K({
        critical: new SchemaField$K({
          allow: new BooleanField$C(),
          bonus: new FormulaField()
        }),
        parts: new ArrayField$l(new DamageField())
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      damage: {
        critical: {
          allow: false,
          bonus: source.system.critical?.damage ?? ""
        },
        parts: options.versatile
          ? [this.transformDamagePartData(source, [source.system.damage?.versatile, ""])]
          : (source.system.damage?.parts?.map(part => this.transformDamagePartData(source, part)) ?? [])
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel(this.damage.parts, rollData);
  }

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

  /** @inheritDoc */
  getDamageConfig(config={}) {
    const rollConfig = super.getDamageConfig(config);

    rollConfig.critical ??= {};
    rollConfig.critical.allow ??= this.damage.critical.allow;
    rollConfig.critical.bonusDamage ??= this.damage.critical.bonus;

    return rollConfig;
  }
}

/**
 * Activity for rolling damage.
 */
class DamageActivity extends ActivityMixin(DamageActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.DAMAGE"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "damage",
      img: "systems/dnd5e/icons/svg/activity/damage.svg",
      title: "DND5E.DAMAGE.Title",
      sheetClass: DamageSheet,
      usage: {
        actions: {
          rollDamage: DamageActivity.#rollDamage
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.damage.parts.length ) return super._usageChatButtons(message);
    return [{
      label: game.i18n.localize("DND5E.Damage"),
      icon: '<i class="fa-solid fa-burst" inert></i>',
      dataset: {
        action: "rollDamage"
      }
    }].concat(super._usageChatButtons(message));
  }

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

  /** @override */
  async _triggerSubsequentActions(config, results) {
    this.rollDamage({ event: config.event }, {}, { data: { "flags.dnd5e.originatingMessage": results.message?.id } });
  }

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

  /**
   * Handle performing a damage roll.
   * @this {DamageActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollDamage(event, target, message) {
    this.rollDamage({ event });
  }
}

/**
 * Sheet for the enchant activity.
 */
class EnchantSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["enchant-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/enchant-effect.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/enchant-enchantments.hbs",
        "systems/dnd5e/templates/activity/parts/enchant-restrictions.hbs"
      ]
    }
  };

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

  /** @override */
  tabGroups = {
    sheet: "identity",
    activation: "time",
    effect: "enchantments"
  };

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

  /** @override */
  _prepareAppliedEffectContext(context, effect) {
    effect.activityOptions = this.item.system.activities
      .filter(a => a.id !== this.activity.id)
      .map(a => ({ value: a.id, label: a.name, selected: effect.data.riders.activity.has(a.id) }));
    effect.effectOptions = context.allEffects.map(e => ({
      ...e, selected: effect.data.riders.effect.has(e.value)
    }));
    return effect;
  }

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);

    const appliedEnchantments = new Set(context.activity.effects?.map(e => e._id) ?? []);
    context.allEnchantments = this.item.effects
      .filter(e => e.type === "enchantment")
      .map(effect => ({
        value: effect.id, label: effect.name, selected: appliedEnchantments.has(effect.id)
      }));

    const enchantableTypes = this.activity.enchantableTypes;
    context.typeOptions = [
      { value: "", label: game.i18n.localize("DND5E.ENCHANT.FIELDS.restrictions.type.Any") },
      ...Object.keys(CONFIG.Item.dataModels)
        .filter(t => enchantableTypes.has(t))
        .map(value => ({ value, label: game.i18n.localize(CONFIG.Item.typeLabels[value]) }))
    ];

    const type = context.source.restrictions.type;
    const typeDataModel = CONFIG.Item.dataModels[type];
    if ( typeDataModel ) context.categoryOptions = Object.entries(typeDataModel.itemCategories ?? {})
      .map(([value, config]) => ({ value, label: foundry.utils.getType(config) === "string" ? config : config.label }));

    context.propertyOptions = (CONFIG.DND5E.validProperties[type] ?? [])
      .map(value => ({ value, label: CONFIG.DND5E.itemProperties[value]?.label ?? value }));

    return context;
  }

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

  /** @inheritDoc */
  _getTabs() {
    const tabs = super._getTabs();
    tabs.effect.label = "DND5E.ENCHANT.SECTIONS.Enchanting";
    tabs.effect.icon = "fa-solid fa-wand-sparkles";
    tabs.effect.tabs = this._markTabs({
      enchantments: {
        id: "enchantments", group: "effect", icon: "fa-solid fa-star",
        label: "DND5E.ENCHANT.SECTIONS.Enchantments"
      },
      restrictions: {
        id: "restrictions", group: "effect", icon: "fa-solid fa-ban",
        label: "DND5E.ENCHANT.SECTIONS.Restrictions"
      }
    });
    return tabs;
  }

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

  /** @override */
  _addEffectData() {
    return {
      type: "enchantment",
      name: this.item.name,
      img: this.item.img,
      disabled: true
    };
  }
}

const { StringField: StringField$10 } = foundry.data.fields;

/**
 * Dialog for configuring the usage of an activity.
 */
class EnchantUsageDialog extends ActivityUsageDialog {

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    creation: {
      template: "systems/dnd5e/templates/activity/enchant-usage-creation.hbs"
    }
  };

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

  /** @inheritDoc */
  async _prepareCreationContext(context, options) {
    context = await super._prepareCreationContext(context, options);

    const enchantments = this.activity.availableEnchantments;
    if ( (enchantments.length > 1) && this._shouldDisplay("create.enchantment") ) {
      context.hasCreation = true;
      context.enchantment = {
        field: new StringField$10({ label: game.i18n.localize("DND5E.ENCHANTMENT.Label") }),
        name: "enchantmentProfile",
        value: this.config.enchantmentProfile,
        options: enchantments.map(e => ({ value: e._id, label: e.effect.name }))
      };
    } else if ( enchantments.length ) {
      context.enchantment = enchantments[0]?._id ?? false;
    }

    return context;
  }
}

/**
 * Special case StringField that includes automatic validation for identifiers.
 */
class IdentifierField extends foundry.data.fields.StringField {
  /** @override */
  _validateType(value) {
    if ( !dnd5e.utils.validators.isValidIdentifier(value) ) {
      throw new Error(game.i18n.localize("DND5E.IdentifierError"));
    }
  }
}

const {
  ArrayField: ArrayField$k, BooleanField: BooleanField$B, DocumentIdField: DocumentIdField$8, DocumentUUIDField: DocumentUUIDField$3, NumberField: NumberField$E, SchemaField: SchemaField$J, SetField: SetField$t, StringField: StringField$$
} = foundry.data.fields;

/**
 * @typedef {EffectApplicationData} EnchantEffectApplicationData
 * @property {object} level
 * @property {number} level.min             Minimum level at which this profile can be used.
 * @property {number} level.max             Maximum level at which this profile can be used.
 * @property {object} riders
 * @property {Set<string>} riders.activity  IDs of other activities on this item that will be added when enchanting.
 * @property {Set<string>} riders.effect    IDs of other effects on this item that will be added when enchanting.
 * @property {Set<string>} riders.item      UUIDs of items that will be added with this enchantment.
 */

/**
 * Data model for a enchant activity.
 *
 * @property {object} enchant
 * @property {string} enchant.identifier    Class identifier that will be used to determine applicable level.
 * @property {object} restrictions
 * @property {boolean} restrictions.allowMagical    Allow enchantments to be applied to items that are already magical.
 * @property {Set<string>} restrictions.categories  Item categories to restrict to.
 * @property {Set<string>} restrictions.properties  Item properties to restrict to.
 * @property {string} restrictions.type             Item type to which this enchantment can be applied.
 */
class EnchantActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      effects: new ArrayField$k(new AppliedEffectField({
        level: new SchemaField$J({
          min: new NumberField$E({ min: 0, integer: true }),
          max: new NumberField$E({ min: 0, integer: true })
        }),
        riders: new SchemaField$J({
          activity: new SetField$t(new DocumentIdField$8()),
          effect: new SetField$t(new DocumentIdField$8()),
          item: new SetField$t(new DocumentUUIDField$3())
        })
      })),
      enchant: new SchemaField$J({
        identifier: new IdentifierField()
      }),
      restrictions: new SchemaField$J({
        allowMagical: new BooleanField$B(),
        categories: new SetField$t(new StringField$$()),
        properties: new SetField$t(new StringField$$()),
        type: new StringField$$()
      })
    };
  }

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

  /** @override */
  get actionType() {
    return "ench";
  }

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

  /** @override */
  get applicableEffects() {
    return null;
  }

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

  /**
   * Enchantments that have been applied by this activity.
   * @type {ActiveEffect5e[]}
   */
  get appliedEnchantments() {
    return dnd5e.registry.enchantments.applied(this.uuid);
  }

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

  /**
   * Enchantments that can be applied based on spell/character/class level.
   * @type {EnchantEffectApplicationData[]}
   */
  get availableEnchantments() {
    const keyPath = (this.item.type === "spell") && (this.item.system.level > 0) ? "item.level"
      : this.classIdentifier ? `classes.${this.classIdentifier}.levels` : "details.level";
    const level = foundry.utils.getProperty(this.getRollData(), keyPath) ?? 0;
    return this.effects
      .filter(e => e.effect && ((e.level.min ?? -Infinity) <= level) && (level <= (e.level.max ?? Infinity)));
  }

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

  /**
   * List of item types that are enchantable.
   * @type {Set<string>}
   */
  static get enchantableTypes() {
    return Object.entries(CONFIG.Item.dataModels).reduce((set, [k, v]) => {
      if ( v.metadata?.enchantable ) set.add(k);
      return set;
    }, new Set());
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static transformEffectsData(source, options) {
    const effects = [];
    for ( const effect of source.effects ) {
      if ( (effect.type !== "enchantment") && (effect.flags?.dnd5e?.type !== "enchantment") ) continue;
      effects.push({ _id: effect._id, ...(effect.flags?.dnd5e?.enchantment ?? {}) });
      delete effect.flags?.dnd5e?.enchantment;
    }
    return effects;
  }

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

  /** @override */
  static transformTypeData(source, activityData) {
    return foundry.utils.mergeObject(activityData, {
      enchant: {
        identifier: source.system.enchantment?.classIdentifier ?? ""
      },
      restrictions: source.system.enchantment?.restrictions ?? []
    });
  }
}

/**
 * Activity for enchanting items.
 */
class EnchantActivity extends ActivityMixin(EnchantActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.ENCHANT"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "enchant",
      img: "systems/dnd5e/icons/svg/activity/enchant.svg",
      title: "DND5E.ENCHANT.Title",
      sheetClass: EnchantSheet,
      usage: {
        dialog: EnchantUsageDialog
      }
    }, { inplace: false })
  );

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

  /** @inheritDoc */
  static localize() {
    super.localize();
    this._localizeSchema(this.schema.fields.effects.element, ["DND5E.ENCHANT.FIELDS.effects"]);
  }

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

  /**
   * List of item types that are enchantable.
   * @type {Set<string>}
   */
  get enchantableTypes() {
    return Object.entries(CONFIG.Item.dataModels).reduce((set, [k, v]) => {
      if ( v.metadata?.enchantable ) set.add(k);
      return set;
    }, new Set());
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /**
   * @typedef {ActivityUseConfiguration} EnchantUseConfiguration
   * @property {string} enchantmentProfile
   */

  /** @inheritDoc */
  _createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig) {
    const config = super._createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig);
    config.enchantmentProfile = usageConfig.enchantmentProfile ?? null;
    return config;
  }

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

  /** @inheritDoc */
  _applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options) {
    super._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options);
    if ( config.enchantmentProfile ) usageConfig.enchantmentProfile = config.enchantmentProfile;
  }

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

  /** @inheritDoc */
  _prepareUsageConfig(config) {
    config = super._prepareUsageConfig(config);
    config.enchantmentProfile ??= this.availableEnchantments[0]?._id;
    return config;
  }

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

  /** @inheritDoc */
  async _prepareUsageScaling(usageConfig, messageConfig, item) {
    await super._prepareUsageScaling(usageConfig, messageConfig, item);

    // Store selected enchantment profile in message flag
    if ( usageConfig.enchantmentProfile ) foundry.utils.setProperty(
      messageConfig, "data.flags.dnd5e.use.enchantmentProfile", usageConfig.enchantmentProfile
    );
  }

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

  /** @inheritDoc */
  _requiresConfigurationDialog(config) {
    return super._requiresConfigurationDialog(config) || (this.availableEnchantments.length > 1);
  }

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

  /**
   * Determine whether the provided item can be enchanted based on this enchantment's restrictions.
   * @param {Item5e} item  Item that might be enchanted.
   * @returns {true|EnchantmentError[]}
   */
  canEnchant(item) {
    const errors = [];

    if ( !this.restrictions.allowMagical && item.system.properties?.has("mgc") ) {
      errors.push(new EnchantmentError$1(game.i18n.localize("DND5E.ENCHANT.Warning.NoMagicalItems")));
    }

    if ( this.restrictions.type && (item.type !== this.restrictions.type) ) {
      errors.push(new EnchantmentError$1(game.i18n.format("DND5E.ENCHANT.Warning.WrongType", {
        incorrectType: game.i18n.localize(CONFIG.Item.typeLabels[item.type]),
        allowedType: game.i18n.localize(CONFIG.Item.typeLabels[this.restrictions.type])
      })));
    }

    if ( this.restrictions.categories.size && !this.restrictions.categories.has(item.system.type?.value) ) {
      const getLabel = key => {
        const config = CONFIG.Item.dataModels[this.restrictions.type]?.itemCategories[key];
        if ( !config ) return key;
        if ( foundry.utils.getType(config) === "string" ) return config;
        return config.label;
      };
      errors.push(new EnchantmentError$1(game.i18n.format(
        `DND5E.ENCHANT.Warning.${item.system.type?.value ? "WrongType" : "NoSubtype"}`,
        {
          allowedType: game.i18n.getListFormatter({ type: "disjunction" }).format(
            Array.from(this.restrictions.categories).map(c => getLabel(c).toLowerCase())
          ),
          incorrectType: getLabel(item.system.type?.value)
        }
      )));
    }

    if ( this.restrictions.properties.size
      && !this.restrictions.properties.intersection(item.system.properties ?? new Set()).size ) {
      errors.push(new EnchantmentError$1(game.i18n.format("DND5E.Enchantment.Warning.MissingProperty", {
        validProperties: game.i18n.getListFormatter({ type: "disjunction" }).format(
          Array.from(this.restrictions.properties).map(p => CONFIG.DND5E.itemProperties[p]?.label ?? p)
        )
      })));
    }

    /**
     * A hook event that fires while validating whether an enchantment can be applied to a specific item.
     * @function dnd5e.canEnchant
     * @memberof hookEvents
     * @param {EnchantActivity} activity   The activity performing the enchanting.
     * @param {Item5e} item                Item to which the enchantment will be applied.
     * @param {EnchantmentError[]} errors  List of errors containing failed restrictions. The item will be enchanted
     *                                     so long as no errors are listed, otherwise the provided errors will be
     *                                     displayed to the user.
     */
    Hooks.callAll("dnd5e.canEnchant", this, item, errors);

    return errors.length ? errors : true;
  }
}

/**
 * Error to throw when an item cannot be enchanted.
 */
let EnchantmentError$1 = class EnchantmentError extends Error {
  constructor(...args) {
    super(...args);
    this.name = "EnchantmentError";
  }
};

/**
 * Sheet for the forward activity.
 */
class ForwardSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["forward-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    identity: {
      template: "systems/dnd5e/templates/activity/forward-identity.hbs",
      templates: super.PARTS.identity.templates
    },
    activation: {
      template: "systems/dnd5e/templates/activity/forward-activation.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-consumption.hbs"
      ]
    },
    effect: {
      template: "systems/dnd5e/templates/activity/forward-effect.hbs"
    }
  };

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

  /** @inheritDoc */
  async _prepareActivationContext(context) {
    context = await super._prepareActivationContext(context);
    context.showConsumeSpellSlot = false;
    context.showScaling = true;
    return context;
  }

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);
    context.activityOptions = [
      { value: "", label: "" },
      ...this.item.system.activities.contents
        .filter(a => (a.type !== "forward") && (CONFIG.DND5E.activityTypes[a.type] !== false))
        .map(activity => ({ value: activity.id, label: activity.name }))
    ];
    return context;
  }

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

  /**
   * Prepare the tab information for the sheet.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @protected
   */
  _getTabs() {
    return this._markTabs({
      identity: {
        id: "identity", group: "sheet", icon: "fa-solid fa-tag",
        label: "DND5E.ACTIVITY.SECTIONS.Identity"
      },
      activation: {
        id: "activation", group: "sheet", icon: "fa-solid fa-boxes-stacked",
        label: "DND5E.CONSUMPTION.FIELDS.consumption.label"
      },
      effect: {
        id: "effect", group: "sheet", icon: "fa-solid fa-sun",
        label: "DND5E.ACTIVITY.SECTIONS.Effect"
      }
    });
  }
}

const { DocumentIdField: DocumentIdField$7, SchemaField: SchemaField$I } = foundry.data.fields;

/**
 * Data model for a Forward activity.
 *
 * @property {object} activity
 * @property {string} activity.id  ID of the activity to forward to.
 */
class ForwardActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    const schema = super.defineSchema();
    delete schema.duration;
    delete schema.effects;
    delete schema.range;
    delete schema.target;
    return {
      ...schema,
      activity: new SchemaField$I({
        id: new DocumentIdField$7()
      })
    };
  }
}

/**
 * Activity for triggering another activity with modified consumption.
 */
class ForwardActivity extends ActivityMixin(ForwardActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.FORWARD"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "forward",
      img: "systems/dnd5e/icons/svg/activity/forward.svg",
      title: "DND5E.FORWARD.Title",
      sheetClass: ForwardSheet
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  async use(usage={}, dialog={}, message={}) {
    const usageConfig = foundry.utils.mergeObject({
      cause: {
        activity: this.relativeUUID
      },
      consume: {
        resources: false,
        spellSlot: false
      }
    }, usage);

    const activity = this.item.system.activities.get(this.activity.id);
    if ( !activity ) ui.notifications.error("DND5E.FORWARD.Warning.NoActivity", { localize: true });
    return activity?.use(usageConfig, dialog, message);
  }
}

/**
 * Sheet for the healing activity.
 */
class HealSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["heal-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/heal-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/heal-healing.hbs"
      ]
    }
  };

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);
    context.typeOptions = Object.entries(CONFIG.DND5E.healingTypes).map(([value, config]) => ({
      value, label: config.label, selected: context.activity.healing.types.has(value)
    }));
    const scaleKey = (this.item.type === "spell" && this.item.system.level === 0) ? "labelCantrip" : "label";
    context.scalingOptions = [
      { value: "", label: game.i18n.localize("DND5E.DAMAGE.Scaling.None") },
      ...Object.entries(CONFIG.DND5E.damageScalingModes).map(([value, { [scaleKey]: label }]) => ({ value, label }))
    ];
    return context;
  }
}

/**
 * Data model for an heal activity.
 *
 * @property {DamageData} healing
 */
class HealActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      healing: new DamageField()
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      healing: this.transformDamagePartData(source, source.system.damage?.parts?.[0] ?? ["", ""])
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel([this.healing], rollData);
  }

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

  /** @override */
  getDamageConfig(config={}) {
    if ( !this.healing.formula ) return foundry.utils.mergeObject({ rolls: [] }, config);

    const rollConfig = foundry.utils.mergeObject({ critical: { allow: false }, scaling: 0 }, config);
    const rollData = this.getRollData();
    rollConfig.rolls = [this._processDamagePart(this.healing, rollConfig, rollData)].concat(config.rolls ?? []);

    return rollConfig;
  }
}

/**
 * Activity for rolling healing.
 */
class HealActivity extends ActivityMixin(HealActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.HEAL"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "heal",
      img: "systems/dnd5e/icons/svg/activity/heal.svg",
      title: "DND5E.HEAL.Title",
      sheetClass: HealSheet,
      usage: {
        actions: {
          rollHealing: HealActivity.#rollHealing
        }
      }
    }, { inplace: false })
  );

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

  /** @override */
  get damageFlavor() {
    return game.i18n.localize("DND5E.HealingRoll");
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.healing.formula ) return super._usageChatButtons(message);
    return [{
      label: game.i18n.localize("DND5E.Healing"),
      icon: '<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/damage/healing.svg"></i>',
      dataset: {
        action: "rollHealing"
      }
    }].concat(super._usageChatButtons(message));
  }

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

  /** @override */
  async _triggerSubsequentActions(config, results) {
    this.rollDamage({ event: config.event }, {}, { data: { "flags.dnd5e.originatingMessage": results.message?.id } });
  }

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

  /**
   * Handle performing a healing roll.
   * @this {HealActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollHealing(event, target, message) {
    this.rollDamage({ event });
  }
}

const { DocumentIdField: DocumentIdField$6, FilePathField: FilePathField$1, StringField: StringField$_ } = foundry.data.fields;

/**
 * Data model for an order activity.
 * @property {string} order  The issued order.
 */
class OrderActivityData extends BaseActivityData {
  /** @override */
  static defineSchema() {
    return {
      _id: new DocumentIdField$6({ initial: () => foundry.utils.randomID() }),
      type: new StringField$_({
        blank: false, required: true, readOnly: true, initial: () => this.metadata.type
      }),
      name: new StringField$_({ initial: undefined }),
      img: new FilePathField$1({ initial: undefined, categories: ["IMAGE"], base64: false }),
      order: new StringField$_({ required: true, blank: false, nullable: false })
    };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    this.img = CONFIG.DND5E.facilities.orders[this.order]?.icon || this.metadata?.img;
  }
}

const { BooleanField: BooleanField$A, DocumentUUIDField: DocumentUUIDField$2, NumberField: NumberField$D, StringField: StringField$Z } = foundry.data.fields;

/**
 * Dialog for configuring the usage of an order activity.
 */
class OrderUsageDialog extends ActivityUsageDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      deleteOccupant: OrderUsageDialog.#onDeleteOccupant,
      removeCraft: OrderUsageDialog.#onRemoveCraft
    }
  };

  /** @override */
  static PARTS = {
    order: {
      template: "systems/dnd5e/templates/activity/order-usage.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

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

  /**
   * Prepare render context for the build section.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  _prepareBuildContext(context, options) {
    context.build = {
      choices: CONFIG.DND5E.facilities.sizes,
      field: new StringField$Z({ nullable: false, blank: false, label: "DND5E.FACILITY.FIELDS.size.label" }),
      name: "building.size",
      value: this.config.building?.size ?? "cramped"
    };
  }

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

  /**
   * Prepare render context for the costs section.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @param {number} options.days               The cost in days.
   * @param {number} options.gold               The cost in gold.
   * @protected
   */
  _prepareCostsContext(context, { days, gold }) {
    const { duration } = game.settings.get("dnd5e", "bastionConfiguration");
    context.costs = {
      days: {
        field: new NumberField$D({ nullable: true, integer: true, min: 0, label: "DND5E.TimeDay" }),
        name: "costs.days",
        value: this.config.costs?.days ?? days ?? duration
      },
      gold: {
        field: new NumberField$D({ nullable: true, integer: true, min: 0, label: "DND5E.CurrencyGP" }),
        name: "costs.gold",
        value: this.config.costs?.gold ?? gold ?? 0
      }
    };
  }

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

  /**
   * Prepare render context for the craft section.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  async _prepareCraftContext(context, options) {
    const { craft } = this.item.system;
    context.craft = {
      legend: game.i18n.localize(`DND5E.FACILITY.Orders.${this.activity.order}.present`),
      item: {
        field: new DocumentUUIDField$2(),
        name: "craft.item",
        value: this.config.craft?.item ?? ""
      }
    };

    if ( this.activity.order === "harvest" ) {
      context.craft.isHarvesting = true;
      context.craft.item.value = this.config.craft?.item ?? craft.item ?? "";
      context.craft.quantity = {
        field: new NumberField$D({ nullable: false, integer: true, positive: true }),
        name: "craft.quantity",
        value: this.config.craft?.quantity ?? craft.quantity ?? 1
      };
    } else {
      context.craft.baseItem = {
        field: new BooleanField$A({
          label: "DND5E.FACILITY.Craft.BaseItem.Label",
          hint: "DND5E.FACILITY.Craft.BaseItem.Hint"
        }),
        name: "craft.buyBaseItem",
        value: this.config.craft?.buyBaseItem ?? false
      };
    }

    if ( context.craft.item.value ) {
      const item = await fromUuid(context.craft.item.value);
      context.craft.value = {
        img: item.img,
        name: item.name,
        contentLink: item.toAnchor().outerHTML
      };
    }
  }

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

  /**
   * Prepare render context for the enlarge order.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @returns {{ days: number, gold: number }}  The costs associated with performing this order.
   * @protected
   */
  _prepareEnlargeContext(context, options) {
    const sizes = Object.entries(CONFIG.DND5E.facilities.sizes).sort((a, b) => a.value - b.value);
    const index = sizes.findIndex(([size]) => size === this.item.system.size);
    const [, current] = sizes[index];
    const [, target] = sizes[index + 1];
    context.description = `
      <span class="current">${current.label}</span>
      <span class="separator">➡</span>
      <span class="target">${target.label}</span>
    `;
    const days = this.item.system.type.value === "basic" ? target.days - current.days : 0;
    return { days, gold: target.value - current.value };
  }

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

  /** @override */
  async _prepareFooterContext(context, options) {
    context.buttons = [{
      action: "use",
      type: "button",
      icon: "fas fa-hand-point-right",
      label: "DND5E.FACILITY.Order.Execute"
    }];
    return context;
  }

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

  /**
   * Prepare render context for orders.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  async _prepareOrderContext(context, options) {
    if ( this.activity.order === "enlarge" ) {
      const { days, gold } = this._prepareEnlargeContext(context, options);
      this._prepareCostsContext(context, { ...options, days, gold });
      return;
    }

    if ( this.activity.order === "build" ) {
      const { days, value: gold } = CONFIG.DND5E.facilities.sizes.cramped;
      this._prepareBuildContext(context, options);
      this._prepareCostsContext(context, { ...options, days, gold });
      return;
    }

    let { duration } = game.settings.get("dnd5e", "bastionConfiguration");
    if ( (this.activity.order === "craft") || (this.activity.order === "harvest") ) {
      await this._prepareCraftContext(context, options);
    }
    else if ( this.activity.order === "trade" ) await this._prepareTradeContext(context, options);
    else {
      const config = CONFIG.DND5E.facilities.orders[this.activity.order];
      if ( config?.duration ) duration = config.duration;
    }

    this._prepareCostsContext(context, { ...options, days: duration });
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "order": await this._prepareOrderContext(context, options); break;
    }
    return context;
  }

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

  /**
   * Prepare render context for the trade order.
   * @param {ApplicationRenderContext} context  Render context.
   * @param {HandlebarsRenderOptions} options   Render options.
   * @protected
   */
  async _prepareTradeContext(context, options) {
    const { trade } = this.item.system;
    if ( !trade.creatures.max && !trade.stock.max ) {
      context.trade = {
        stocked: {
          field: new BooleanField$A({
            label: "DND5E.FACILITY.Trade.Stocked.Label",
            hint: "DND5E.FACILITY.Trade.Stocked.Hint"
          }),
          name: "trade.stock.stocked",
          value: this.config.trade?.stock?.stocked ?? false
        }
      };
    } else {
      const isSelling = this.config.trade?.sell ?? false;
      context.trade = {
        sell: {
          field: new BooleanField$A({ label: "DND5E.FACILITY.Trade.Sell.Label" }),
          name: "trade.sell",
          value: isSelling
        }
      };

      if ( trade.stock.max ) {
        const max = isSelling ? trade.stock.value || 0 : trade.stock.max - (trade.stock.value ?? 0);
        const label = `DND5E.FACILITY.Trade.Stock.${isSelling ? "Sell" : "Buy"}`;
        context.trade.stock = {
          field: new NumberField$D({ label, max, min: 0, step: 1, nullable: false }),
          name: "trade.stock.value",
          value: this.config.trade?.stock?.value ?? 0
        };
      } else if ( trade.creatures.max ) {
        const sell = await Promise.all(trade.creatures.value.map(async (uuid, i) => {
          const doc = await fromUuid(uuid);
          return {
            contentLink: doc.toAnchor().outerHTML,
            field: new BooleanField$A(),
            name: "trade.creatures.sell",
            value: this.config.trade?.creatures?.sell?.[i] ?? false
          };
        }));
        const buy = await Promise.all(Array.fromRange(trade.creatures.max).map(async i => {
          let removable = true;
          let uuid = trade.creatures.value[i];
          if ( uuid ) removable = false;
          else uuid = this.config.trade?.creatures?.buy?.[i];
          const doc = await fromUuid(uuid);
          if ( doc ) return { removable, uuid, img: doc.img, name: doc.name };
          return { empty: true };
        }));
        context.trade.creatures = {
          buy, sell,
          hint: "DND5E.FACILITY.Trade.Creatures.Buy",
          price: {
            field: new NumberField$D({
              nullable: false, min: 0, integer: true,
              label: "DND5E.FACILITY.Trade.Price.Label",
              hint: "DND5E.FACILITY.Trade.Price.Hint"
            }),
            name: "trade.creatures.price",
            value: this.config.trade?.creatures?.price ?? 0
          }
        };
      }
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("drop", this._onDrop.bind(this));
  }

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

  /**
   * Handle drops onto the dialog.
   * @param {DragEvent} event  The drag-drop event.
   * @protected
   */
  _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    if ( (data.type !== "Actor") || !data.uuid ) return;
    const { trade } = this.item.system;
    if ( !this.config.trade?.creatures?.buy ) {
      this.config.trade ??= {};
      this.config.trade.creatures ??= {};
      this.config.trade.creatures.buy = [];
    }
    const index = Math.max(trade.creatures.value.length, this.config.trade.creatures.buy.length);
    if ( index + 1 > trade.creatures.max ) return;
    this.config.trade.creatures.buy[index] = data.uuid;
    this.render();
  }

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

  /**
   * Prepare submission data for build orders.
   * @param {object} submitData  Submission data.
   * @protected
   */
  _prepareBuildData(submitData) {
    if ( (this.config.building?.size ?? "cramped") !== submitData.building?.size ) {
      const { days, value: gold } = CONFIG.DND5E.facilities.sizes[submitData.building.size];
      Object.assign(submitData.costs, { days, gold });
    }
  }

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

  /**
   * Prepare submission data for craft orders.
   * @param {object} submitData  Submission data.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareCraftData(submitData) {
    let recalculateCosts = submitData.craft.item !== this.config.craft?.item;
    recalculateCosts ||= submitData.craft.buyBaseItem !== this.config.craft?.buyBaseItem;
    if ( (this.activity.order === "craft") && recalculateCosts ) {
      const item = await fromUuid(submitData.craft.item);
      const { days, gold } = await item.system.getCraftCost({
        baseItem: submitData.craft.buyBaseItem ? "buy" : "craft"
      });
      Object.assign(submitData.costs, { days, gold });
    }
  }

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

  /** @inheritDoc */
  async _prepareSubmitData(event, formData) {
    const submitData = await super._prepareSubmitData(event, formData);
    if ( "building" in submitData ) this._prepareBuildData(submitData);
    if ( submitData.craft?.item ) await this._prepareCraftData(submitData);
    if ( "trade" in submitData ) await this._prepareTradeData(submitData);
    return submitData;
  }

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

  /**
   * Prepare submission data for trade orders.
   * @param {object} submitData  Submission data.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareTradeData(submitData) {
    // Clear data when toggling trade mode.
    if ( ("trade" in this.config) && (submitData.trade.sell !== this.config.trade?.sell) ) {
      delete this.config.trade.stock;
      delete this.config.trade.creatures;
      submitData.costs.gold = 0;
    }

    if ( ("stock" in submitData.trade) && !submitData.trade.sell ) {
      submitData.costs.gold = submitData.trade.stock.value;
    }

    if ( "creatures" in submitData.trade && !submitData.trade.sell ) {
      const buy = [];
      const { creatures } = submitData.trade;
      Object.keys(creatures.buy ?? {}).forEach(k => buy[k] = creatures.buy[k]);
      submitData.trade.creatures.buy = buy;
    }
  }

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

  /**
   * Handle removing a configured occupant.
   * @this {OrderUsageDialog}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The event target.
   */
  static #onDeleteOccupant(event, target) {
    const { index } = target.closest("[data-index]")?.dataset ?? {};
    this.config.trade.creatures.buy.splice(index, 1);
    this.render();
  }

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

  /**
   * Handle clearing the currently configured item for crafting.
   * @this {OrderUsageDialog}
   */
  static #onRemoveCraft() {
    delete this.config.craft.item;
    this.render();
  }
}

/**
 * @typedef AwardOptions
 * @property {Record<string, number>|null} currency  Amount of each currency to award.
 * @property {boolean} each                          Distribute full award to each destination, rather than dividing it
 *                                                   among the destinations.
 * @property {Set<string>} savedDestinations         Set of IDs for previously selected destinations.
 * @property {number|null} xp                        Amount of experience points to award.
 */

/**
 * Application for awarding XP and currency to players.
 */
class Award extends Application5e {
  constructor(options, _options={}) {
    if ( options instanceof foundry.abstract.Document ) {
      foundry.utils.logCompatibilityWarning(
        "The `Award` origin actor should now be passed within the options object as `origin`.",
        { since: "DnD5e 4.3", until: "DnD5e 4.5" }
      );
      _options.origin = options;
      options = _options;
    }

    if ( !options ) options = _options;
    for ( const key of ["currency", "each", "savedDestinations", "xp"] ) {
      if ( !(key in options) ) continue;
      options.award ??= {};
      options.award[key] = options[key];
      delete options[key];
      foundry.utils.logCompatibilityWarning(
        `The \`${key}\` option in \`Award\` has been moved to \`award.${key}\`.`,
        { since: "DnD5e 4.3", until: "DnD5e 4.5" }
      );
    }

    super(options);
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    award: {
      currency: null,
      each: false,
      savedDestinations: new Set(),
      xp: null
    },
    classes: ["award", "standard-form"],
    form: {
      handler: Award.#handleFormSubmission
    },
    origin: null,
    position: {
      width: 350
    },
    tag: "form",
    window: {
      title: "DND5E.Award.Title"
    }
  };

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

  /** @override */
  static PARTS = {
    award: {
      template: "systems/dnd5e/templates/apps/award.hbs"
    }
  };

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

  /**
   * Award options.
   * @type {AwardOptions}
   */
  get award() {
    return this.options.award;
  }

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

  /**
   * Group actor from which this award is being granted.
   * @type {Actor5e}
   */
  get origin() {
    return this.options.origin;
  }

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

  /**
   * Destinations to which XP & currency can be awarded.
   * @type {Actor5e[]}
   */
  get transferDestinations() {
    if ( this.isPartyAward ) return this.origin.system.transferDestinations ?? [];
    if ( !game.user.isGM ) return [];
    const primaryParty = game.settings.get("dnd5e", "primaryParty")?.actor;
    return primaryParty
      ? [primaryParty, ...primaryParty.system.transferDestinations]
      : game.users.map(u => u.character).filter(c => c);
  }

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

  /**
   * Is this award coming from a party group actor rather than the /award command?
   * @type {boolean}
   */
  get isPartyAward() {
    return this.origin?.system.type?.value === "party";
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.currency = Object.entries(CONFIG.DND5E.currencies).reduce((obj, [k, { label, icon }]) => {
      obj[k] = {
        label, icon,
        value: this.award.currency ? this.award.currency[k] : this.origin?.system.currency[k]
      };
      return obj;
    }, {});
    context.destinations = Award.prepareDestinations(this.transferDestinations, this.award.savedDestinations);
    context.each = this.award.each ?? false;
    context.hideXP = game.settings.get("dnd5e", "levelingMode") === "noxp";
    context.noPrimaryParty = !game.settings.get("dnd5e", "primaryParty")?.actor && !this.isPartyAward;
    context.xp = this.award.xp ?? this.origin?.system.details.xp.value ?? this.origin?.system.details.xp.derived;

    return context;
  }

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

  /**
   * Apply type icons to transfer destinations and prepare them for display in the list.
   * @param {Document[]} destinations          Destination documents to prepare.
   * @param {Set<string>} [savedDestinations]  IDs of targets to pre-check.
   * @returns {{doc: Document, icon: string}[]}
   */
  static prepareDestinations(destinations, savedDestinations) {
    const icons = {
      container: '<dnd5e-icon class="fa-fw" src="systems/dnd5e/icons/svg/backpack.svg"></dnd5e-icon>',
      group: '<i class="fa-solid fa-people-group"></i>',
      vehicle: '<i class="fa-solid fa-sailboat"></i>'
    };
    return destinations.map(doc => ({
      doc, checked: savedDestinations?.has(doc.id), icon: icons[doc.type] ?? '<i class="fa-solid fa-fw fa-user"></i>'
    }));
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this._validateForm();
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    this._validateForm();
  }

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

  /**
   * Ensure the award form is in a valid form to be submitted.
   * @protected
   */
  _validateForm() {
    const formData = new FormDataExtended(this.element);
    const data = foundry.utils.expandObject(formData.object);
    let valid = true;
    if ( !filteredKeys(data.amount ?? {}).length && !data.xp ) valid = false;
    if ( !filteredKeys(data.destination ?? {}).length ) valid = false;
    this.element.querySelector('button[name="transfer"]').disabled = !valid;
  }

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

  /**
   * Handle submitting the award form.
   * @this {Award}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    const data = foundry.utils.expandObject(formData.object);
    const destinations = this.transferDestinations.filter(d => data.destination[d.id]);
    const each = data.each;
    this._saveDestinations(destinations);
    const results = new Map();
    await this.constructor.awardCurrency(data.amount, destinations, { each, origin: this.origin, results });
    await this.constructor.awardXP(data.xp, destinations, { each, origin: this.origin, results });
    this.constructor.displayAwardMessages(results);
    this.close();
  }

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

  /**
   * Save the selected destination IDs to either the current group's flags or the user's flags.
   * @param {Actor5e[]} destinations  Selected destinations to save.
   * @protected
   */
  _saveDestinations(destinations) {
    const target = this.isPartyAward ? this.origin : game.user;
    target.setFlag("dnd5e", "awardDestinations", destinations);
  }

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

  /**
   * Award currency, optionally transferring between one document and another.
   * @param {Record<string, number>} amounts   Amount of each denomination to transfer.
   * @param {(Actor5e|Item5e)[]} destinations  Documents that should receive the currency.
   * @param {object} [config={}]
   * @param {boolean} [config.each=false]      Award the specified amount to each player, rather than splitting it.
   * @param {Actor5e|Item5e} [config.origin]   Document from which to move the currency, if not a freeform award.
   * @param {Map<Actor5e|Item5e, object>} [config.results]  Results of the award operation.
   */
  static async awardCurrency(amounts, destinations, { each=false, origin, results=new Map() }={}) {
    if ( !destinations.length ) return;
    const originCurrency = origin ? foundry.utils.deepClone(origin.system.currency) : null;

    for ( const k of Object.keys(amounts) ) {
      if ( each ) amounts[k] = amounts[k] * destinations.length;
      if ( origin ) amounts[k] = Math.min(amounts[k], originCurrency[k] ?? 0);
    }

    let remainingDestinations = destinations.length;
    for ( const destination of destinations ) {
      const destinationUpdates = {};
      if ( !results.has(destination) ) results.set(destination, {});
      const result = results.get(destination).currency ??= {};

      for ( let [key, amount] of Object.entries(amounts) ) {
        if ( !amount ) continue;
        amount = Math.clamp(
          // Divide amount between remaining destinations
          Math.floor(amount / remainingDestinations),
          // Ensure negative amounts aren't more than is contained in destination
          -destination.system.currency[key],
          // Ensure positive amounts aren't more than is contained in origin
          originCurrency ? originCurrency[key] : Infinity
        );
        amounts[key] -= amount;
        if ( originCurrency ) originCurrency[key] -= amount;
        destinationUpdates[`system.currency.${key}`] = destination.system.currency[key] + amount;
        result[key] = amount;
      }

      await destination.update(destinationUpdates);
      remainingDestinations -= 1;
    }

    if ( origin ) await origin.update({ "system.currency": originCurrency });
  }

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

  /**
   * Award XP split across the provided destination actors.
   * @param {number} amount            Amount of XP to award.
   * @param {Actor5e[]} destinations   Actors that should receive the XP.
   * @param {object} [config={}]
   * @param {boolean} [config.each=false]      Award the specified amount to each player, rather than splitting it.
   * @param {Actor5e} [config.origin]  Group actor from which to transfer the XP.
   * @param {Map<Actor5e|Item5e, object>} [config.results]  Results of the award operation.
   */
  static async awardXP(amount, destinations, { each=false, origin, results=new Map() }={}) {
    destinations = destinations.filter(d => ["character", "group"].includes(d.type));
    if ( !amount || !destinations.length ) return;

    const xp = origin?.system.details.xp;
    let originUpdate = origin ? (xp.value ?? xp.derived ?? 0) : Infinity;
    if ( each ) amount = amount * destinations.length;
    const perDestination = Math.floor(Math.min(amount, originUpdate) / destinations.length);
    originUpdate -= amount;
    for ( const destination of destinations ) {
      await destination.update({ "system.details.xp.value": destination.system.details.xp.value + perDestination });
      if ( !results.has(destination) ) results.set(destination, {});
      const result = results.get(destination);
      result.xp = perDestination;
    }

    if ( Number.isFinite(originUpdate) ) await origin.update({ "system.details.xp.value": originUpdate });
  }

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

  /**
   * Display chat messages for awarded currency and XP.
   * @param {Map<Actor5e|Item5e, object>} results  Results of any award operations.
   */
  static async displayAwardMessages(results) {
    const cls = getDocumentClass("ChatMessage");
    const messages = [];
    for ( const [destination, result] of results ) {
      const entries = [];
      for ( const [key, amount] of Object.entries(result.currency ?? {}) ) {
        const label = CONFIG.DND5E.currencies[key].label;
        entries.push(`
          <span class="award-entry">
            ${formatNumber(amount)} <i class="currency ${key}" data-tooltip="${label}" aria-label="${label}"></i>
          </span>
        `);
      }
      if ( result.xp ) entries.push(`
        <span class="award-entry">
          ${formatNumber(result.xp)} ${game.i18n.localize("DND5E.ExperiencePoints.Abbreviation")}
        </span>
      `);
      if ( !entries.length ) continue;

      const content = game.i18n.format("DND5E.Award.Message", {
        name: destination.name, award: `<span class="dnd5e2">${game.i18n.getListFormatter().format(entries)}</span>`
      });

      const whisperTargets = game.users.filter(user => destination.testUserPermission(user, "OWNER"));
      const whisper = whisperTargets.length !== game.users.size;
      const messageData = {
        content,
        whisper: whisper ? whisperTargets : []
      };
      messages.push(messageData);
    }
    if ( messages.length ) cls.createDocuments(messages);
  }

  /* -------------------------------------------- */
  /*  Chat Command                                */
  /* -------------------------------------------- */

  /**
   * Regular expression used to match the /award command in chat messages.
   * @type {RegExp}
   */
  static COMMAND_PATTERN = new RegExp(/^\/award(?:\s|$)/i);

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

  /**
   * Regular expression used to split currency & xp values from their labels.
   * @type {RegExp}
   */
  static VALUE_PATTERN = new RegExp(/^(.+?)(\D+)$/);

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

  /**
   * Use the `chatMessage` hook to determine if an award command was typed.
   * @param {string} message   Text of the message being posted.
   * @returns {boolean|void}   Returns `false` to prevent the message from continuing to parse.
   */
  static chatMessage(message) {
    if ( !this.COMMAND_PATTERN.test(message) ) return;
    this.handleAward(message);
    return false;
  }

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

  /**
   * Parse the award command and grant an award.
   * @param {string} message  Award command typed in chat.
   */
  static async handleAward(message) {
    if ( !game.user.isGM ) {
      ui.notifications.error("DND5E.Award.NotGMError", { localize: true });
      return;
    }

    try {
      const { currency, xp, party, each } = this.parseAwardCommand(message);

      for ( const [key, formula] of Object.entries(currency) ) {
        const roll = new Roll(formula);
        await roll.evaluate();
        currency[key] = roll.total;
      }

      // If the party command is set, a primary party is set, and the award isn't empty, skip the UI
      const primaryParty = game.settings.get("dnd5e", "primaryParty")?.actor;
      if ( party && primaryParty && (xp || filteredKeys(currency).length) ) {
        const destinations = each ? primaryParty.system.playerCharacters : [primaryParty];
        const results = new Map();
        await this.awardCurrency(currency, destinations, { each, results });
        await this.awardXP(xp, destinations, { each, results });
        this.displayAwardMessages(results);
      }

      // Otherwise show the UI with defaults
      else {
        const savedDestinations = game.user.getFlag("dnd5e", "awardDestinations");
        const app = new Award({ award: { currency, xp, each, savedDestinations } });
        app.render({ force: true });
      }
    } catch(err) {
      ui.notifications.warn(err.message);
    }
  }

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

  /**
   * Parse the award command.
   * @param {string} message  Award command typed in chat.
   * @returns {{currency: Record<string, number>, xp: number, party: boolean}}
   */
  static parseAwardCommand(message) {
    const command = message.replace(this.COMMAND_PATTERN, "").toLowerCase();

    const currency = {};
    let each = false;
    let party = false;
    let xp;
    const unrecognized = [];
    for ( const part of command.split(" ") ) {
      if ( !part ) continue;
      let [, amount, label] = part.match(this.VALUE_PATTERN) ?? [];
      label = label?.toLowerCase();
      try {
        new Roll(amount);
        if ( label in CONFIG.DND5E.currencies ) currency[label] = amount;
        else if ( label === "xp" ) xp = Number(amount);
        else if ( part === "each" ) each = true;
        else if ( part === "party" ) party = true;
        else throw new Error();
      } catch(err) {
        unrecognized.push(part);
      }
    }

    // Display warning about an unrecognized commands
    if ( unrecognized.length ) throw new Error(game.i18n.format("DND5E.Award.UnrecognizedWarning", {
      commands: game.i18n.getListFormatter().format(unrecognized.map(u => `"${u}"`))
    }));

    return { currency, xp, each, party };
  }
}

/**
 * Application for performing currency conversions & transfers.
 */
class CurrencyManager extends Application5e {
  constructor(options, _options={}) {
    if ( options instanceof foundry.abstract.Document ) {
      foundry.utils.logCompatibilityWarning(
        "The `CurrencyManager` document should now be passed within the options object as `document`.",
        { since: "DnD5e 4.3", until: "DnD5e 4.5" }
      );
      _options.document = options;
      options = _options;
    }
    super(options);
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    actions: {
      setAll: CurrencyManager.#setTransferValue,
      setHalf: CurrencyManager.#setTransferValue
    },
    classes: ["currency-manager", "standard-form"],
    document: null,
    form: {
      closeOnSubmit: true,
      handler: CurrencyManager.#handleFormSubmission
    },
    position: {
      width: 350
    },
    tag: "form",
    window: {
      title: "DND5E.CurrencyManager.Title"
    }
  };

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

  /** @override */
  static PARTS = {
    tabs: {
      template: "templates/generic/tab-navigation.hbs"
    },
    convert: {
      template: "systems/dnd5e/templates/apps/currency-manager-convert.hbs"
    },
    transfer: {
      template: "systems/dnd5e/templates/apps/currency-manager-transfer.hbs"
    }
  };

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

  /** @override */
  tabGroups = {
    primary: "transfer"
  };

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

  /**
   * Document for which the currency is being managed.
   * @type {Actor5e|Item5e}
   */
  get document() {
    return this.options.document;
  }

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

  /**
   * Destinations to which currency can be transferred.
   * @type {(Actor5e|Item5e)[]}
   */
  get transferDestinations() {
    const destinations = [];
    const actor = this.document instanceof Actor ? this.document : this.document.parent;
    if ( actor && (actor !== this.document) ) destinations.push(actor);
    destinations.push(...(actor?.system.transferDestinations ?? []));
    destinations.push(...(actor?.itemTypes.container.filter(b => b !== this.document) ?? []));
    if ( game.user.isGM ) {
      const primaryParty = game.settings.get("dnd5e", "primaryParty")?.actor;
      if ( primaryParty && (this.document !== primaryParty) && !destinations.includes(primaryParty) ) {
        destinations.push(primaryParty);
      }
    }
    return destinations;
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.currency = this.document.system.currency;
    context.destinations = Award.prepareDestinations(this.transferDestinations);
    context.tabs = this._getTabs();

    return context;
  }

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

  /** @override */
  async _preparePartContext(partId, context) {
    context = await super._preparePartContext(partId, context);
    context.tab = context.tabs[partId];
    return context;
  }

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

  /**
   * Prepare the tab information for the sheet.
   * @returns {Record<string, Partial<ApplicationTab>>}
   * @protected
   */
  _getTabs() {
    return {
      convert: {
        id: "convert", group: "primary", icon: "fa-solid fa-arrow-up-short-wide",
        label: "DND5E.CurrencyManager.Convert.Label",
        active: this.tabGroups.primary === "convert",
        cssClass: this.tabGroups.primary === "convert" ? "active" : ""
      },
      transfer: {
        id: "transfer", group: "primary", icon: "fa-solid fa-reply-all fa-flip-horizontal",
        label: "DND5E.CurrencyManager.Transfer.Label",
        active: this.tabGroups.primary === "transfer",
        cssClass: this.tabGroups.primary === "transfer" ? "active" : ""
      }
    };
  }

  /* -------------------------------------------- */
  /*  Event Handling                              */
  /* -------------------------------------------- */

  /**
   * Handle setting the transfer amount based on the buttons.
   * @this {CurrencyManager}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   * @protected
   */
  static #setTransferValue(event, target) {
    for ( let [key, value] of Object.entries(this.document.system.currency) ) {
      if ( target.dataset.action === "setHalf" ) value = Math.floor(value / 2);
      const input = this.element.querySelector(`[name="amount.${key}"]`);
      if ( input && value ) input.value = value;
    }
    this._validateForm();
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    this._validateForm();
  }

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

  /**
   * Ensure the transfer form is in a valid form to be submitted.
   * @protected
   */
  _validateForm() {
    const formData = new FormDataExtended(this.element);
    const data = foundry.utils.expandObject(formData.object);
    let valid = true;
    if ( !filteredKeys(data.amount ?? {}).length ) valid = false;
    if ( !filteredKeys(data.destination ?? {}).length ) valid = false;
    this.element.querySelector('button[name="transfer"]').disabled = !valid;
  }

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

  /**
   * Handle submitting the currency manager form.
   * @this {Award}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    const data = foundry.utils.expandObject(formData.object);
    switch ( event.submitter?.name ) {
      case "convert":
        await this.constructor.convertCurrency(this.document);
        break;
      case "transfer":
        const destinations = this.transferDestinations.filter(d => data.destination[d.id]);
        await this.constructor.transferCurrency(this.document, destinations, data.amount);
        break;
    }
  }

  /* -------------------------------------------- */
  /*  Currency Operations                         */
  /* -------------------------------------------- */

  /**
   * Convert all carried currency to the highest possible denomination using configured conversion rates.
   * See CONFIG.DND5E.currencies for configuration.
   * @param {Actor5e|Item5e} doc  Actor or container item to convert.
   * @returns {Promise<Actor5e|Item5e>}
   */
  static convertCurrency(doc) {
    const currency = foundry.utils.deepClone(doc.system.currency);

    const currencies = Object.entries(CONFIG.DND5E.currencies);
    currencies.sort((a, b) => a[1].conversion - b[1].conversion);

    // Count total converted units of the base currency
    let basis = currencies.reduce((change, [denomination, config]) => {
      if ( !config.conversion ) return change;
      return change + (currency[denomination] / config.conversion);
    }, 0);

    // Convert base units into the highest denomination possible
    for ( const [denomination, config] of currencies) {
      if ( !config.conversion ) continue;
      const amount = Math.floor(basis * config.conversion);
      currency[denomination] = amount;
      basis -= (amount / config.conversion);
    }

    // Save the updated currency object
    return doc.update({ "system.currency": currency });
  }

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

  /**
   * Deduct a certain amount of currency from a given Actor.
   * @param {Actor5e} actor                          The actor.
   * @param {number} amount                          The amount of currency.
   * @param {string} denomination                    The currency's denomination.
   * @param {object} [options]
   * @param {boolean} [options.recursive=false]      Deduct currency from containers as well as the base Actor. TODO
   * @param {"high"|"low"} [options.priority="low"]  Prioritize higher denominations before lower, or vice-versa.
   * @param {boolean} [options.exact=true]           Prioritize deducting the requested denomination first.
   * @throws {Error} If the Actor does not have sufficient currency.
   * @returns {Promise<Actor5e>|void}
   */
  static deductActorCurrency(actor, amount, denomination, options={}) {
    if ( amount <= 0 ) return;
    // eslint-disable-next-line no-unused-vars
    const { item, remainder, ...updates } = this.getActorCurrencyUpdates(actor, amount, denomination, options);
    if ( remainder ) throw new Error(game.i18n.format("DND5E.CurrencyManager.Error.InsufficientFunds", {
      denomination,
      amount: new Intl.NumberFormat(game.i18n.lang).format(amount),
      name: actor.name
    }));
    return actor.update(updates);
  }

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

  /**
   * Determine model updates for deducting a certain amount of currency from a given Actor.
   * @param {Actor5e} actor                          The actor.
   * @param {number} amount                          The amount of currency.
   * @param {string} denomination                    The currency's denomination.
   * @param {object} [options]
   * @param {boolean} [options.recursive=false]      Deduct currency from containers as well as the base Actor. TODO
   * @param {"high"|"low"} [options.priority="low"]  Prioritize higher denominations before lower, or vice-versa.
   * @param {boolean} [options.exact=true]           Prioritize deducting the requested denomination first.
   * @returns {{ item: object[], remainder: number, [p: string]: any }}
   */
  static getActorCurrencyUpdates(actor, amount, denomination, { recursive=false, priority="low", exact=true }={}) {
    const { currency } = actor.system;
    const updates = { system: { currency: { ...currency } }, remainder: amount, item: [] };
    if ( amount <= 0 ) return updates;

    const currencies = Object.entries(CONFIG.DND5E.currencies).map(([denom, { conversion }]) => {
      return [denom, conversion];
    }).sort(([, a], [, b]) => priority === "high" ? a - b : b - a);
    const baseConversion = CONFIG.DND5E.currencies[denomination].conversion;

    if ( exact ) currencies.unshift([denomination, baseConversion]);
    for ( const [denom, conversion] of currencies ) {
      const multiplier = conversion / baseConversion;
      const deduct = Math.min(updates.system.currency[denom], Math.floor(updates.remainder * multiplier));
      updates.remainder -= deduct / multiplier;
      updates.system.currency[denom] -= deduct;
      if ( !updates.remainder ) return updates;
    }

    return updates;
  }

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

  /**
   * Transfer currency between one document and another.
   * @param {Actor5e|Item5e} origin       Document from which to move the currency.
   * @param {Document[]} destinations     Documents that should receive the currency.
   * @param {object[]} amounts            Amount of each denomination to transfer.
   */
  static async transferCurrency(origin, destinations, amounts) {
    Award.awardCurrency(amounts, destinations, { origin });
  }
}

/**
 * @typedef {ActivityUseConfiguration} OrderUseConfiguration
 * @property {object} [building]
 * @property {string} [building.size]            The size of facility to build.
 * @property {object} [costs]
 * @property {number} [costs.days]               The cost of executing the order, in days.
 * @property {number} [costs.gold]               The cost of executing the order, in gold.
 * @property {boolean} [costs.paid]              Whether the gold cost has been paid.
 * @property {object} [craft]
 * @property {string} [craft.item]               The item being crafted or harvested.
 * @property {number} [craft.quantity]           The quantity of items to harvest.
 * @property {object} [trade]
 * @property {boolean} [trade.sell]              Whether the trade was a sell operation.
 * @property {object} [trade.stock]
 * @property {boolean} [trade.stock.stocked]     Whether the order was to fully stock the inventory.
 * @property {boolean} [trade.stock.value]       The base value of goods transacted.
 * @property {object} [trade.creatures]
 * @property {string[]} [trade.creatures.buy]    Additional animals purchased.
 * @property {boolean[]} [trade.creatures.sell]  Whether a creature in a given slot was sold.
 * @property {number} [trade.creatures.price]    The base value of the animals sold.
 */

/**
 * An activity for issuing an order to a facility.
 */
class OrderActivity extends ActivityMixin(OrderActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    type: "order",
    img: "systems/dnd5e/icons/svg/activity/order.svg",
    title: "DND5E.FACILITY.Order.Issue",
    usage: {
      actions: {
        pay: OrderActivity.#onPayOrder
      },
      chatCard: "systems/dnd5e/templates/chat/order-activity-card.hbs",
      dialog: OrderUsageDialog
    }
  }, { inplace: false }));

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

  /** @inheritDoc */
  get canUse() {
    return super.canUse
      // Don't allow usage if facility is already executing the same order
      && !this.inProgress
      // Enlarge order cannot be executed if facility is already maximum size
      && ((this.order !== "enlarge") || (this.parent.size !== "vast"));
  }

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

  /**
   * Is this order currently in the process of being executed by its facility?
   * @type {boolean}
   */
  get inProgress() {
    if ( this.parent.progress.order !== this.order ) return false;
    // TODO: Ideally this would also check to see if the order has already been paid,
    // but that information is only part of the chat message and there isn't a clean
    // way to retrieve it at the moment
    return this.parent.progress.value > 0;
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /**
   * Update building configuration.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeBuild(usageConfig, updates) {
    updates["system.building.size"] = usageConfig.building.size;
  }

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

  /**
   * Update costs.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeCosts(usageConfig, updates) {
    const { costs } = usageConfig;
    if ( costs.days ) updates["system.progress"] = { value: 0, max: costs.days, order: this.order };
  }

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

  /**
   * Update crafting configuration.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeCraft(usageConfig, updates) {
    const { craft } = usageConfig;
    updates["system.craft"] = { item: craft.item, quantity: 1 };
    if ( this.order === "harvest" ) updates["system.craft"].quantity = craft.quantity;
  }

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

  /**
   * Update facility size.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeEnlarge(usageConfig, updates) {
    // Special facilities enlarge immediately.
    if ( (this.item.system.type.value !== "special") || (this.item.system.size === "vast") ) return;
    const sizes = Object.entries(CONFIG.DND5E.facilities.sizes).sort((a, b) => a.value - b.value);
    const index = sizes.findIndex(([size]) => size === this.item.system.size);
    updates["system.size"] = sizes[index + 1][0];
  }

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

  /**
   * Update trading configuration.
   * @param {OrderUseConfiguration} usageConfig  Order configuration.
   * @param {object} updates                     Item updates.
   * @protected
   */
  _finalizeTrade(usageConfig, updates) {
    const { costs, trade } = usageConfig;
    const { system } = this.item;
    updates["system.trade.pending.operation"] = trade.sell ? "sell" : "buy";
    updates["system.trade.pending.creatures"] = [];
    updates["system.trade.pending.value"] = null;
    if ( trade.stock ) {
      if ( "stocked" in trade.stock ) {
        updates["system.trade.pending.stocked"] = trade.stock.stocked;
        updates["system.trade.pending.operation"] = trade.stock.stocked ? "buy" : null;
      }
      else updates["system.trade.pending.value"] = trade.stock.value;
    }
    if ( trade.creatures ) {
      let creatures = (trade.creatures.buy ?? []).filter(_ => _);
      if ( trade.sell ) {
        creatures = [];
        for ( let i = 0; i < trade.creatures.sell?.length ?? 0; i++ ) {
          const sold = trade.creatures.sell[i];
          if ( sold ) creatures.push(system.trade.creatures.value[i]);
        }
      }
      updates["system.trade.pending.value"] = trade.sell ? (trade.creatures.price ?? 0) : costs.gold;
      updates["system.trade.pending.creatures"] = creatures;
      // TODO: We need a way to visualize 'pending' animal purchases/sales. For now update immediately.
      updates["system.trade.creatures.value"] = trade.sell
        ? system.trade.creatures.value.filter((_, i) => !trade.creatures.sell[i])
        : system.trade.creatures.value.concat(creatures);
    }
  }

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

  /** @override */
  async _finalizeUsage(usageConfig, results) {
    const updates = {};
    switch ( this.order ) {
      case "build": this._finalizeBuild(usageConfig, updates); break;
      case "craft":
      case "harvest":
        this._finalizeCraft(usageConfig, updates);
        break;
      case "enlarge": this._finalizeEnlarge(usageConfig, updates); break;
      case "trade": this._finalizeTrade(usageConfig, updates); break;
    }
    this._finalizeCosts(usageConfig, updates);
    return this.item.update(updates);
  }

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

  /** @override */
  _prepareUsageConfig(config) {
    config.consume = false;
    return config;
  }

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

  /** @override */
  _prepareUsageScaling(usageConfig, messageConfig, item) {
    // FIXME: No scaling happening here, but this is the only context we have both usageConfig and messageConfig.
    const { costs, craft, trade } = usageConfig;
    messageConfig.data.flags.dnd5e.order = { costs, craft, trade };
  }

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

  /** @override */
  _requiresConfigurationDialog(config) {
    return true;
  }

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

  /** @override */
  _usageChatButtons(message) {
    const { costs } = message.data.flags.dnd5e.order;
    if ( !costs.gold || costs.paid ) return [];
    return [{
      label: game.i18n.localize("DND5E.FACILITY.Costs.Automatic"),
      icon: '<i class="fas fa-coins"></i>',
      dataset: { action: "pay", method: "automatic" }
    }, {
      label: game.i18n.localize("DND5E.FACILITY.Costs.Manual"),
      icon: '<i class="fas fa-clipboard-check"></i>',
      dataset: { action: "pay", method: "manual" }
    }];
  }

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

  /** @override */
  async _usageChatContext(message) {
    const { costs, craft, trade } = message.data.flags.dnd5e.order;
    const { type } = this.item.system;
    const supplements = [];
    if ( costs.days ) supplements.push(`
      <strong>${game.i18n.localize("DND5E.DurationTime")}</strong>
      ${game.i18n.format("DND5E.FACILITY.Costs.Days", { days: costs.days })}
    `);
    if ( costs.gold ) supplements.push(`
      <strong>${game.i18n.localize("DND5E.CurrencyGP")}</strong>
      ${formatNumber(costs.gold)}
      (${game.i18n.localize(`DND5E.FACILITY.Costs.${costs.paid ? "Paid" : "Unpaid"}`)})
    `);
    if ( craft?.item ) {
      const item = await fromUuid(craft.item);
      supplements.push(`
        <strong>${game.i18n.localize("DOCUMENT.Items")}</strong>
        ${craft.quantity > 1 ? `${craft.quantity}&times;` : ""}
        ${item.toAnchor().outerHTML}
      `);
    }
    if ( trade?.stock?.value && trade.sell ) supplements.push(`
      <strong>${game.i18n.localize("DND5E.FACILITY.Trade.Sell.Supplement")}</strong>
      ${formatNumber(trade.stock.value)}
      ${CONFIG.DND5E.currencies.gp?.abbreviation ?? ""}
    `);
    if ( trade?.creatures ) {
      const creatures = [];
      if ( trade.sell ) {
        for ( let i = 0; i < trade.creatures.sell.length; i++ ) {
          const sold = trade.creatures.sell[i];
          if ( sold ) creatures.push(await fromUuid(this.item.system.trade.creatures.value[i]));
        }
      }
      else creatures.push(...await Promise.all(trade.creatures.buy.filter(_ => _).map(fromUuid)));
      supplements.push(`
        <strong>${game.i18n.localize(`DND5E.FACILITY.Trade.${trade.sell ? "Sell" : "Buy"}.Supplement`)}</strong>
        ${game.i18n.getListFormatter({ style: "narrow" }).format(creatures.map(a => a.toAnchor().outerHTML))}
      `);
    }
    const facilityType = game.i18n.localize(`DND5E.FACILITY.Types.${type.value.titleCase()}.Label.one`);
    const buttons = this._usageChatButtons(message);
    return {
      supplements,
      buttons: buttons.length ? buttons : null,
      description: game.i18n.format("DND5E.FACILITY.Use.Description", {
        order: game.i18n.localize(`DND5E.FACILITY.Orders.${this.order}.inf`),
        link: this.item.toAnchor().outerHTML,
        facilityType: facilityType.toLocaleLowerCase(game.i18n.lang)
      })
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle deducting currency for the order.
   * @this {OrderActivity}
   * @param {PointerEvent} event     The triggering event.
   * @param {HTMLElement} target     The button that was clicked.
   * @param {ChatMessage5e} message  The message associated with the activation.
   * @returns {Promise<void>}
   */
  static async #onPayOrder(event, target, message) {
    const { method } = target.dataset;
    const order = message.getFlag("dnd5e", "order");
    const config = foundry.utils.expandObject({ "data.flags.dnd5e.order": order });
    if ( method === "automatic" ) {
      try {
        await CurrencyManager.deductActorCurrency(this.actor, order.costs.gold, "gp", {
          recursive: true,
          priority: "high"
        });
      } catch(err) {
        ui.notifications.error(err.message);
        return;
      }
    }
    foundry.utils.setProperty(config, "data.flags.dnd5e.order.costs.paid", true);
    const context = await this._usageChatContext(config);
    const content = await renderTemplate(this.metadata.usage.chatCard, context);
    await message.update({ content, flags: config.data.flags });
  }
}

/**
 * Sheet for the save activity.
 */
class SaveSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["save-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    effect: {
      template: "systems/dnd5e/templates/activity/save-effect.hbs",
      templates: [
        ...super.PARTS.effect.templates,
        "systems/dnd5e/templates/activity/parts/damage-part.hbs",
        "systems/dnd5e/templates/activity/parts/damage-parts.hbs",
        "systems/dnd5e/templates/activity/parts/save-damage.hbs",
        "systems/dnd5e/templates/activity/parts/save-details.hbs",
        "systems/dnd5e/templates/activity/parts/save-effect-settings.hbs"
      ]
    }
  };

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

  /** @override */
  _prepareAppliedEffectContext(context, effect) {
    effect.additionalSettings = "systems/dnd5e/templates/activity/parts/save-effect-settings.hbs";
    return effect;
  }

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);

    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({
      value, label: config.label
    }));
    context.calculationOptions = [
      { value: "", label: game.i18n.localize("DND5E.SAVE.FIELDS.save.dc.CustomFormula") },
      { rule: true },
      { value: "spellcasting", label: game.i18n.localize("DND5E.SpellAbility") },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, config]) => ({
        value, label: config.label, group: game.i18n.localize("DND5E.Abilities")
      }))
    ];
    context.onSaveOptions = [
      { value: "none", label: game.i18n.localize("DND5E.SAVE.FIELDS.damage.onSave.None") },
      { value: "half", label: game.i18n.localize("DND5E.SAVE.FIELDS.damage.onSave.Half") },
      { value: "full", label: game.i18n.localize("DND5E.SAVE.FIELDS.damage.onSave.Full") }
    ];

    return context;
  }
}

const { ArrayField: ArrayField$j, BooleanField: BooleanField$z, SchemaField: SchemaField$H, SetField: SetField$s, StringField: StringField$Y } = foundry.data.fields;

/**
 * @typedef {EffectApplicationData} SaveEffectApplicationData
 * @property {boolean} onSave  Should this effect still be applied on a successful save?
 */

/**
 * Data model for an save activity.
 *
 * @property {object} damage
 * @property {string} damage.onSave                 How much damage is done on a successful save?
 * @property {DamageData[]} damage.parts            Parts of damage to inflict.
 * @property {SaveEffectApplicationData[]} effects  Linked effects that can be applied.
 * @property {object} save
 * @property {Set<string>} save.ability             Make the saving throw with one of these abilities.
 * @property {object} save.dc
 * @property {string} save.dc.calculation           Method or ability used to calculate the difficulty class.
 * @property {string} save.dc.formula               Custom DC formula or flat value.
 */
class SaveActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      damage: new SchemaField$H({
        onSave: new StringField$Y(),
        parts: new ArrayField$j(new DamageField())
      }),
      effects: new ArrayField$j(new AppliedEffectField({
        onSave: new BooleanField$z()
      })),
      save: new SchemaField$H({
        ability: new SetField$s(new StringField$Y()),
        dc: new SchemaField$H({
          calculation: new StringField$Y({ initial: "initial" }),
          formula: new FormulaField({ deterministic: true })
        })
      })
    };
  }

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

  /** @override */
  get ability() {
    if ( this.save.dc.calculation in CONFIG.DND5E.abilities ) return this.save.dc.calculation;
    if ( this.save.dc.calculation === "spellcasting" ) return this.spellcastingAbility;
    return this.save.ability.first() ?? null;
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static migrateData(source) {
    if ( foundry.utils.getType(source.save?.ability) === "string" ) {
      if ( source.save.ability ) source.save.ability = [source.save.ability];
      else source.save.ability = [];
    }
  }

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

  /** @override */
  static transformTypeData(source, activityData, options) {
    let calculation = source.system.save?.scaling;
    if ( calculation === "flat" ) calculation = "";
    else if ( calculation === "spell" ) calculation = "spellcasting";

    return foundry.utils.mergeObject(activityData, {
      damage: {
        onSave: (source.type === "spell") && (source.system.level === 0) ? "none" : "half",
        parts: source.system.damage?.parts?.map(part => this.transformDamagePartData(source, part)) ?? []
      },
      save: {
        ability: [source.system.save?.ability || Object.keys(CONFIG.DND5E.abilities)[0]],
        dc: {
          calculation,
          formula: String(source.system.save?.dc ?? "")
        }
      }
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    if ( !this.damage.onSave ) this.damage.onSave = this.isSpell && (this.item.system.level === 0) ? "none" : "half";
    if ( this.save.dc.calculation === "initial" ) this.save.dc.calculation = this.isSpell ? "spellcasting" : "";
    this.save.dc.bonus = "";
  }

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

  /** @inheritDoc */
  prepareFinalData(rollData) {
    rollData ??= this.getRollData({ deterministic: true });
    super.prepareFinalData(rollData);
    this.prepareDamageLabel(this.damage.parts, rollData);

    const bonus = this.save.dc.bonus ? simplifyBonus(this.save.dc.bonus, rollData) : 0;

    let ability;
    if ( this.save.dc.calculation ) ability = this.ability;
    else this.save.dc.value = simplifyBonus(this.save.dc.formula, rollData);
    this.save.dc.value ??= this.actor?.system.abilities?.[ability]?.dc
      ?? 8 + (this.actor?.system.attributes?.prof ?? 0);
    this.save.dc.value += bonus;

    if ( this.save.dc.value ) this.labels.save = game.i18n.format("DND5E.SaveDC", {
      dc: this.save.dc.value,
      ability: CONFIG.DND5E.abilities[ability]?.label ?? ""
    });
  }

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

  /** @inheritDoc */
  getDamageConfig(config={}) {
    const rollConfig = super.getDamageConfig(config);

    rollConfig.critical ??= {};
    rollConfig.critical.allow ??= false;

    return rollConfig;
  }
}

/**
 * Activity for making saving throws and rolling damage.
 */
class SaveActivity extends ActivityMixin(SaveActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.SAVE"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "save",
      img: "systems/dnd5e/icons/svg/activity/save.svg",
      title: "DND5E.SAVE.Title.one",
      sheetClass: SaveSheet,
      usage: {
        actions: {
          rollDamage: SaveActivity.#rollDamage,
          rollSave: SaveActivity.#rollSave
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    const buttons = [];
    const dc = this.save.dc.value;

    for ( const abilityId of this.save.ability ) {
      const ability = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
      buttons.push({
        label: `
          <span class="visible-dc">${game.i18n.format("DND5E.SavingThrowDC", { dc, ability })}</span>
          <span class="hidden-dc">${game.i18n.format("DND5E.SavePromptTitle", { ability })}</span>
        `,
        icon: '<i class="fa-solid fa-shield-heart" inert></i>',
        dataset: {
          dc,
          ability: abilityId,
          action: "rollSave",
          visibility: "all"
        }
      });
    }

    if ( this.damage.parts.length ) buttons.push({
      label: game.i18n.localize("DND5E.Damage"),
      icon: '<i class="fas fa-burst" inert></i>',
      dataset: {
        action: "rollDamage"
      }
    });
    return buttons.concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async rollDamage(config={}, dialog={}, message={}) {
    message = foundry.utils.mergeObject({
      "data.flags.dnd5e.roll": {
        damageOnSave: this.damage.onSave
      }
    }, message);
    return super.rollDamage(config, dialog, message);
  }

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

  /**
   * Handle performing a damage roll.
   * @this {SaveActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollDamage(event, target, message) {
    this.rollDamage({ event });
  }

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

  /**
   * Handle performing a saving throw.
   * @this {SaveActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static async #rollSave(event, target, message) {
    const targets = getSceneTargets();
    if ( !targets.length && game.user.character ) targets.push(game.user.character);
    if ( !targets.length ) ui.notifications.warn("DND5E.ActionWarningNoToken", { localize: true });
    const dc = parseInt(target.dataset.dc);
    for ( const token of targets ) {
      const actor = token instanceof Actor ? token : token.actor;
      const speaker = ChatMessage.getSpeaker({ actor, scene: canvas.scene, token: token.document });
      await actor.rollSavingThrow({
        event,
        ability: target.dataset.ability ?? this.save.ability.first(),
        target: Number.isFinite(dc) ? dc : this.save.dc.value
      }, {}, { data: { speaker } });
    }
  }

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

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), { save: this.save });
  }
}

/**
 * Sheet for the summon activity.
 */
class SummonSheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["summon-activity"],
    actions: {
      addProfile: SummonSheet.#addProfile,
      deleteProfile: SummonSheet.#deleteProfile
    }
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    identity: {
      template: "systems/dnd5e/templates/activity/summon-identity.hbs",
      templates: super.PARTS.identity.templates
    },
    effect: {
      template: "systems/dnd5e/templates/activity/summon-effect.hbs",
      templates: [
        "systems/dnd5e/templates/activity/parts/activity-effects.hbs",
        "systems/dnd5e/templates/activity/parts/summon-changes.hbs",
        "systems/dnd5e/templates/activity/parts/summon-profiles.hbs"
      ]
    }
  };

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

  /** @inheritDoc */
  static CLEAN_ARRAYS = [...super.CLEAN_ARRAYS, "profiles"];

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

  /** @override */
  tabGroups = {
    sheet: "identity",
    activation: "time",
    effect: "profiles"
  };

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

  /** @inheritDoc */
  async _prepareEffectContext(context) {
    context = await super._prepareEffectContext(context);

    context.abilityOptions = [
      { value: "", label: this.activity.isSpell ? game.i18n.localize("DND5E.Spellcasting") : "" },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];
    context.creatureSizeOptions = Object.entries(CONFIG.DND5E.actorSizes).map(([value, config]) => ({
      value, label: config.label, selected: this.activity.creatureSizes.has(value)
    }));
    context.creatureTypeOptions = Object.entries(CONFIG.DND5E.creatureTypes).map(([value, config]) => ({
      value, label: config.label, selected: this.activity.creatureTypes.has(value)
    }));

    context.profileModes = [
      { value: "", label: game.i18n.localize("DND5E.SUMMON.FIELDS.summon.mode.Direct") },
      { value: "cr", label: game.i18n.localize("DND5E.SUMMON.FIELDS.summon.mode.CR") }
    ];
    context.profiles = this.activity.profiles.map((data, index) => ({
      data, index,
      collapsed: this.expandedSections.get(`profiles.${data._id}`) ? "" : "collapsed",
      fields: this.activity.schema.fields.profiles.element.fields,
      prefix: `profiles.${index}.`,
      source: context.source.profiles[index] ?? data,
      document: data.uuid ? fromUuidSync(data.uuid) : null,
      mode: this.activity.summon.mode,
      typeOptions: this.activity.summon.mode === "cr" ? context.creatureTypeOptions.map(t => ({
        ...t, selected: data.types.has(t.value)
      })) : null
    })).sort((lhs, rhs) =>
      (lhs.name || lhs.document?.name || "").localeCompare(rhs.name || rhs.document?.name || "", game.i18n.lang)
    );

    return context;
  }

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

  /** @inheritDoc */
  _getTabs() {
    const tabs = super._getTabs();
    tabs.effect.label = "DND5E.SUMMON.SECTIONS.Summoning";
    tabs.effect.icon = "fa-solid fa-spaghetti-monster-flying";
    tabs.effect.tabs = this._markTabs({
      profiles: {
        id: "profiles", group: "effect", icon: "fa-solid fa-address-card",
        label: "DND5E.SUMMON.SECTIONS.Profiles"
      },
      changes: {
        id: "changes", group: "effect", icon: "fa-solid fa-sliders",
        label: "DND5E.SUMMON.SECTIONS.Changes"
      }
    });
    return tabs;
  }

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

  /** @inheritDoc */
  _onRender() {
    super._onRender();
    this.element.querySelector(".activity-profiles").addEventListener("drop", this.#onDrop.bind(this));
  }

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

  /**
   * Handle adding a new entry to the summoning profiles list.
   * @this {ActivityConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #addProfile(event, target) {
    this.activity.update({ profiles: [...this.activity.toObject().profiles, {}] });
  }

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

  /**
   * Handle removing an entry from the summoning profiles list.
   * @this {ActivityConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #deleteProfile(event, target) {
    const profiles = this.activity.toObject().profiles;
    profiles.splice(target.closest("[data-index]").dataset.index, 1);
    this.activity.update({ profiles });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle dropping actors onto the sheet.
   * @param {Event} event  Triggering drop event.
   */
  async #onDrop(event) {
    // Try to extract the data
    const data = TextEditor.getDragEventData(event);

    // Handle dropping linked items
    if ( data?.type !== "Actor" ) return;
    const actor = await Actor.implementation.fromDropData(data);

    // If dropped onto existing profile, add or replace link
    const profileId = event.target.closest("[data-profile-id]")?.dataset.profileId;
    if ( profileId ) {
      const profiles = this.activity.toObject().profiles;
      const profile = profiles.find(p => p._id === profileId);
      profile.uuid = actor.uuid;
      this.activity.update({ profiles });
    }

    // Otherwise create a new profile
    else this.activity.update({ profiles: [...this.activity.toObject().profiles, { uuid: actor.uuid }] });
  }
}

const { BooleanField: BooleanField$y, StringField: StringField$X } = foundry.data.fields;

/**
 * Dialog for configuring the usage of the summon activity.
 */
class SummonUsageDialog extends ActivityUsageDialog {

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    creation: {
      template: "systems/dnd5e/templates/activity/summon-usage-creation.hbs"
    }
  };

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

  /** @inheritDoc */
  async _prepareCreationContext(context, options) {
    context = await super._prepareCreationContext(context, options);

    const profiles = this.activity.availableProfiles;
    if ( this._shouldDisplay("create.summons") && (profiles.length || (this.activity.creatureSizes.size > 1)
      || (this.activity.creatureTypes.size > 1)) ) {
      context.hasCreation = true;
      context.summonsFields = [];

      if ( !foundry.utils.hasProperty(this.options.display, "create.summons") ) context.summonsFields.push({
        field: new BooleanField$y({ label: game.i18n.localize("DND5E.SUMMON.Action.Place") }),
        name: "create.summons",
        value: this.config.create?.summons,
        input: context.inputs.createCheckboxInput
      });

      if ( this.config.create?.summons ) {
        const rollData = this.activity.getRollData();
        if ( profiles.length > 1 ) {
          let options = profiles.map(profile => ({
            value: profile._id, label: this.getProfileLabel(profile, rollData)
          }));
          if ( options.every(o => o.label.startsWith("1 × ")) ) {
            options = options.map(({ value, label }) => ({ value, label: label.replace("1 × ", "") }));
          }
          context.summonsFields.push({
            field: new StringField$X({ label: game.i18n.localize("DND5E.SUMMON.Profile.Label") }),
            name: "summons.profile",
            value: this.config.summons?.profile,
            options
          });
        } else context.summonsProfile = profiles[0]._id;

        if ( this.activity.creatureSizes.size > 1 ) context.summonsFields.push({
          field: new StringField$X({ label: game.i18n.localize("DND5E.Size") }),
          name: "summons.creatureSize",
          value: this.config.summons?.creatureSize,
          options: Array.from(this.activity.creatureSizes)
            .map(value => ({ value, label: CONFIG.DND5E.actorSizes[value]?.label }))
            .filter(k => k)
        });

        if ( this.activity.creatureTypes.size > 1 ) context.summonsFields.push({
          field: new StringField$X({ label: game.i18n.localize("DND5E.CreatureType") }),
          name: "summons.creatureType",
          value: this.config.summons?.creatureType,
          options: Array.from(this.activity.creatureTypes)
            .map(value => ({ value, label: CONFIG.DND5E.creatureTypes[value]?.label }))
            .filter(k => k)
        });
      }
    }

    return context;
  }

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

  /**
   * Determine the label for a profile in the ability use dialog.
   * @param {SummonsProfile} profile  Profile for which to generate the label.
   * @param {object} rollData         Roll data used to prepare the count.
   * @returns {string}
   */
  getProfileLabel(profile, rollData) {
    let label;
    if ( profile.name ) label = profile.name;
    else {
      switch ( this.activity.summon.mode ) {
        case "cr":
          const cr = simplifyBonus(profile.cr, rollData);
          label = game.i18n.format("DND5E.SUMMON.Profile.ChallengeRatingLabel", { cr: formatCR(cr) });
          break;
        default:
          const doc = fromUuidSync(profile.uuid);
          if ( doc ) label = doc.name;
          break;
      }
    }
    label ??= "—";

    let count = simplifyRollFormula(Roll.replaceFormulaData(profile.count ?? "1", rollData));
    if ( Number.isNumeric(count) ) count = parseInt(count);
    if ( count ) label = `${count} × ${label}`;

    return label;
  }
}

/**
 * A filter description.
 *
 * @typedef {object} FilterDescription
 * @property {string} k        Key on the data object to check.
 * @property {any} v           Value to compare.
 * @property {string} [o="_"]  Operator or comparison function to use.
 */

/**
 * Check some data against a filter to determine if it matches.
 * @param {object} data                                   Data to check.
 * @param {FilterDescription|FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 * @throws
 */
function performCheck(data, filter=[]) {
  if ( foundry.utils.getType(filter) === "Array" ) return AND(data, filter);
  return _check(data, filter.k, filter.v, filter.o);
}

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

/**
 * Determine the unique keys referenced by a set of filters.
 * @param {FilterDescription[]} filter  Filter to examine.
 * @returns {Set<string>}
 */
function uniqueKeys(filter=[]) {
  const keys = new Set();
  const _uniqueKeys = filters => {
    for ( const f of filters ) {
      const operator = f.o in OPERATOR_FUNCTIONS;
      if ( operator && (foundry.utils.getType(f.v) === "Array") ) _uniqueKeys(f.v);
      else if ( f.o === "NOT" ) _uniqueKeys([f.v]);
      else if ( !operator ) keys.add(f.k);
    }
  };
  _uniqueKeys(filter);
  return keys;
}

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

/**
 * Internal check implementation.
 * @param {object} data             Data to check.
 * @param {string} [keyPath]        Path to individual piece within data to check.
 * @param {*} value                 Value to compare against or additional filters.
 * @param {string} [operation="_"]  Checking function to use.
 * @returns {boolean}
 * @internal
 * @throws
 */
function _check(data, keyPath, value, operation="_") {
  const operator = OPERATOR_FUNCTIONS[operation];
  if ( operator ) return operator(data, value);

  const comparison = COMPARISON_FUNCTIONS[operation];
  if ( !comparison ) throw new Error(`Comparison function "${operation}" could not be found.`);
  return comparison(foundry.utils.getProperty(data, keyPath), value);
}

/* -------------------------------------------- */
/*  Operator Functions                          */
/* -------------------------------------------- */

/**
 * Operator functions.
 * @enum {Function}
 */
const OPERATOR_FUNCTIONS = {
  AND, NAND, OR, NOR, XOR, NOT
};

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

/**
 * Perform an AND check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function AND(data, filter) {
  return filter.every(({k, v, o}) => _check(data, k, v, o));
}

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

/**
 * Perform an NAND check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function NAND(data, filter) {
  return !filter.every(({k, v, o}) => _check(data, k, v, o));
}

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

/**
 * Perform an OR check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function OR(data, filter) {
  return filter.some(({k, v, o}) => _check(data, k, v, o));
}

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

/**
 * Perform an NOR check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function NOR(data, filter) {
  return !filter.some(({k, v, o}) => _check(data, k, v, o));
}

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

/**
 * Perform an XOR check against all filters.
 * @param {object} data                 Data to check.
 * @param {FilterDescription[]} filter  Filter to compare against.
 * @returns {boolean}
 */
function XOR(data, filter) {
  if ( !filter.length ) return false;
  let currentResult = _check(data, filter[0].k, filter[0].v, filter[0].o);
  for ( let i = 1; i < filter.length; i++ ) {
    const { k, v, o } = filter[i];
    currentResult ^= _check(data, k, v, o);
  }
  return Boolean(currentResult);
}

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

/**
 * Invert the result of a nested check,
 * @param {object} data               Data to check.
 * @param {FilterDescription} filter  Filter to compare against.
 * @returns {boolean}
 */
function NOT(data, filter) {
  const { k, v, o } = filter;
  return !_check(data, k, v, o);
}

/* -------------------------------------------- */
/*  Comparison Functions                        */
/* -------------------------------------------- */

/**
 * Currently supported comparison functions.
 * @enum {Function}
 */
const COMPARISON_FUNCTIONS = {
  _: exact, exact, contains, icontains, startswith, istartswith, endswith,
  has, hasany, hasall, in: in_, gt, gte, lt, lte
};

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

/**
 * Check for an exact match. The default comparison mode if none is provided.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function exact(data, value) {
  return data === value;
}

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

/**
 * Check that data contains value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function contains(data, value) {
  return String(data).includes(String(value));
}

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

/**
 * Case-insensitive check that data contains value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function icontains(data, value) {
  return contains(String(data).toLocaleLowerCase(game.i18n.lang), String(value).toLocaleLowerCase(game.i18n.lang));
}

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

/**
 * Check that data starts with value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function startswith(data, value) {
  return String(data).startsWith(String(value));
}

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

/**
 * Case-insensitive check that data starts with value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function istartswith(data, value) {
  return startswith(String(data).toLocaleLowerCase(game.i18n.lang), String(value).toLocaleLowerCase(game.i18n.lang));
}

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

/**
 * Check that data ends with value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function endswith(data, value) {
  return String(data).endsWith(String(value));
}

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

/**
 * Check that the data collection has the provided value.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function has(data, value) {
  // If the value is another filter description, apply that check against each member of the collection
  if ( foundry.utils.getType(value) === "Object" ) {
    switch ( foundry.utils.getType(data) ) {
      case "Array":
      case "Set": return !!data.find(d => performCheck(d, value));
      default: return false;
    }
  } else return in_(value, data);
}

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

/**
 * Check that the data collection has any of the provided values.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function hasany(data, value) {
  return Array.from(value).some(v => has(data, v));
}

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

/**
 * Check that the data collection has all of the provided values.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function hasall(data, value) {
  return Array.from(value).every(v => has(data, v));
}

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

/**
 * Check that data matches one of the provided values.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function in_(data, value) {
  switch ( foundry.utils.getType(value) ) {
    case "Array": return value.includes(data);
    case "Set": return value.has(data);
    default: return false;
  }
}

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

/**
 * Check that value is greater than data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function gt(data, value) {
  return data > value;
}

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

/**
 * Check that value is greater than or equal to data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function gte(data, value) {
  return data >= value;
}

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

/**
 * Check that value is less than data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function lt(data, value) {
  return data < value;
}

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

/**
 * Check that value is less than or equal to data.
 * @param {*} data
 * @param {*} value
 * @returns {boolean}
 */
function lte(data, value) {
  return data <= value;
}

var Filter = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AND: AND,
  COMPARISON_FUNCTIONS: COMPARISON_FUNCTIONS,
  NAND: NAND,
  NOR: NOR,
  NOT: NOT,
  OPERATOR_FUNCTIONS: OPERATOR_FUNCTIONS,
  OR: OR,
  XOR: XOR,
  contains: contains,
  endswith: endswith,
  exact: exact,
  gt: gt,
  gte: gte,
  has: has,
  hasall: hasall,
  hasany: hasany,
  icontains: icontains,
  in_: in_,
  istartswith: istartswith,
  lt: lt,
  lte: lte,
  performCheck: performCheck,
  startswith: startswith,
  uniqueKeys: uniqueKeys
});

const { NumberField: NumberField$C, SchemaField: SchemaField$G, StringField: StringField$W } = foundry.data.fields;

/**
 * @typedef {object} SourceData
 * @property {string} book      Book/publication where the item originated.
 * @property {string} page      Page or section where the item can be found.
 * @property {string} custom    Fully custom source label.
 * @property {string} license   Type of license that covers this item.
 * @property {number} revision  Revision count for this item.
 * @property {string} rules     Version of the rules for this document (e.g. 2014 vs. 2024).
 */

/**
 * Data fields that stores information on the adventure or sourcebook where this document originated.
 */
class SourceField extends SchemaField$G {
  constructor(fields={}, options={}) {
    fields = {
      book: new StringField$W(),
      page: new StringField$W(),
      custom: new StringField$W(),
      license: new StringField$W(),
      revision: new NumberField$C({ initial: 1 }),
      rules: new StringField$W({ initial: "2024" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.SOURCE.FIELDS.source.label", ...options });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the source label.
   * @this {SourceData}
   * @param {string} uuid  Compendium source or document UUID.
   */
  static prepareData(uuid) {
    const pkg = SourceField.getPackage(uuid);
    this.bookPlaceholder = SourceField.getModuleBook(pkg) ?? "";
    if ( !this.book ) this.book = this.bookPlaceholder;

    if ( this.custom ) this.label = this.custom;
    else {
      const page = Number.isNumeric(this.page)
        ? game.i18n.format("DND5E.SOURCE.Display.Page", { page: this.page }) : (this.page ?? "");
      this.label = game.i18n.format("DND5E.SOURCE.Display.Full", { book: this.book, page }).trim();
    }

    this.value = this.book || (pkg?.title ?? "");
    this.slug = this.value.slugify({ strict: true });

    Object.defineProperty(this, "directlyEditable", {
      value: (this.custom ?? "") === this.label,
      configurable: true,
      enumerable: false
    });
  }

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

  /**
   * Check if the provided package has any source books registered in its manifest. If it has only one, then return
   * that book's key.
   * @param {ClientPackage} pkg  The package.
   * @returns {string|null}
   */
  static getModuleBook(pkg) {
    if ( !pkg ) return null;
    const sourceBooks = pkg.flags?.dnd5e?.sourceBooks;
    const keys = Object.keys(sourceBooks ?? {});
    if ( keys.length !== 1 ) return null;
    return keys[0];
  }

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

  /**
   * Get the package associated with the given UUID, if any.
   * @param {string} uuid  The UUID.
   * @returns {ClientPackage|null}
   */
  static getPackage(uuid) {
    if ( !uuid ) return null;
    const pack = foundry.utils.parseUuid(uuid)?.collection?.metadata;
    switch ( pack?.packageType ) {
      case "module": return game.modules.get(pack.packageName);
      case "system": return game.system;
      case "world": return game.world;
    }
    return null;
  }

  /* -------------------------------------------- */
  /*  Shims                                       */
  /* -------------------------------------------- */

  /**
   * Add a shim for the old source path.
   * @this {ActorDataModel}
   */
  static shimActor() {
    const source = this.source;
    Object.defineProperty(this.details, "source", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The source data for actors has been moved to `system.source`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        return source;
      }
    });
  }
}

/**
 * @typedef {ApplicationConfiguration} CompendiumBrowserSourceConfiguration
 * @property {string} [selected]  The initially-selected package.
 */

/**
 * @typedef CompendiumSourceConfig5e
 * @property {object} packages
 * @property {CompendiumSourcePackageConfig5e} packages.world
 * @property {CompendiumSourcePackageConfig5e} packages.system
 * @property {Record<string, CompendiumSourcePackageConfig5e>} packages.modules
 * @property {object} packs
 * @property {CompendiumSourcePackGroup5e} packs.items
 * @property {CompendiumSourcePackGroup5e} packs.actors
 */

/**
 * @typedef CompendiumSourcePackageConfig5e
 * @property {string} title           The package title.
 * @property {string} id              The package ID.
 * @property {number} count           The number of packs provided by this package.
 * @property {boolean} checked        True if all the packs are included.
 * @property {boolean} indeterminate  True if only some of the packs are included.
 * @property {boolean} active         True if the package is currently selected.
 * @property {string} filter          The normalized package title for filtering.
 */

/**
 * @typedef CompendiumSourcePackGroup5e
 * @property {boolean} checked        True if all members of this pack group are included.
 * @property {boolean} indeterminate  True if only some of this pack group are included.
 * @property {CompendiumSourcePackConfig5e[]} entries
 */

/**
 * @typedef CompendiumSourcePackConfig5e
 * @property {string} title     The pack title.
 * @property {string} id        The pack ID.
 * @property {boolean} checked  True if the pack is included.
 */

/**
 * An application for configuring which compendium packs contribute their content to the compendium browser.
 * @extends Application5e<CompendiumBrowserSourceConfiguration>
 */
class CompendiumBrowserSettingsConfig extends Application5e {
  constructor(options) {
    super(options);
    this.#selected = this.options.selected;
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "compendium-browser-source-config",
    classes: ["dialog-lg"],
    tag: "form",
    window: {
      title: "DND5E.CompendiumBrowser.Sources.Label",
      icon: "fas fa-book-open-reader",
      resizable: true
    },
    position: {
      width: 800,
      height: 650
    },
    actions: {
      clearFilter: CompendiumBrowserSettingsConfig.#onClearPackageFilter,
      selectPackage: CompendiumBrowserSettingsConfig.#onSelectPackage
    },
    selected: "system"
  };

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

  /** @override */
  static PARTS = {
    sidebar: {
      id: "sidebar",
      template: "systems/dnd5e/templates/compendium/sources-sidebar.hbs"
    },
    packs: {
      id: "packs",
      template: "systems/dnd5e/templates/compendium/sources-packs.hbs"
    }
  };

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

  /**
   * The number of milliseconds to delay between user keypresses before executing the package filter.
   * @type {number}
   */
  static FILTER_DELAY = 200;

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

  /**
   * The current package filter.
   * @type {string}
   */
  #filter = "";

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

  /**
   * The currently selected package.
   * @type {string}
   */
  #selected;

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

  _debouncedFilter = foundry.utils.debounce(this._onFilterPackages.bind(this), this.constructor.FILTER_DELAY);

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const sources = this.constructor.collateSources();
    const byPackage = { world: new Set(), system: new Set() };

    for ( const { collection, documentName, metadata } of game.packs ) {
      if ( (documentName !== "Actor") && (documentName !== "Item") ) continue;
      let entry;
      if ( (metadata.packageType === "world") || (metadata.packageType === "system") ) {
        entry = byPackage[metadata.packageType];
      }
      else entry = byPackage[`module.${metadata.packageName}`] ??= new Set();
      entry.add(collection);
    }

    const packages = { };
    packages.world = this._preparePackageContext("world", game.world, byPackage.world, sources);
    packages.system = this._preparePackageContext("system", game.system, byPackage.system, sources);

    const modules = Object.entries(byPackage).reduce((arr, [k, packs]) => {
      if ( (k === "world") || (k === "system") ) return arr;
      const id = k.slice(7);
      const module = game.modules.get(id);
      arr.push(this._preparePackageContext(k, module, packs, sources));
      return arr;
    }, []);
    modules.sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang));
    packages.modules = Object.fromEntries(modules.map(m => [m.id, m]));

    const packs = { actors: {}, items: {} };
    [["actors", "Actor"], ["items", "Item"]].forEach(([p, type]) => {
      packs[p] = this._preparePackGroupContext(type, byPackage[this.#selected], sources);
    });

    return {
      ...await super._prepareContext(options),
      packages, packs,
      filter: this.#filter
    };
  }

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

  /**
   * Prepare render context for packages.
   * @param {string} id            The package identifier.
   * @param {ClientPackage} pkg    The package.
   * @param {Set<string>} packs    The packs belonging to this package.
   * @param {Set<string>} sources  The packs currently selected for inclusion.
   * @returns {CompendiumSourcePackageConfig5e}
   * @protected
   */
  _preparePackageContext(id, pkg, packs, sources) {
    const { title } = pkg;
    const all = packs.isSubsetOf(sources);
    const indeterminate = !all && packs.intersects(sources);
    return {
      id, title, indeterminate,
      checked: indeterminate || all,
      count: packs.size,
      active: this.#selected === id,
      filter: title.replace(/[^\p{L} ]/gu, "").toLocaleLowerCase(game.i18n.lang)
    };
  }

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

  /**
   * Prepare render context for pack groups.
   * @param {string} documentType    The pack group's Document type.
   * @param {Set<string>} packs      The packs provided by the selected package.
   * @param {Set<string>} sources    The packs currently selected for inclusion.
   * @returns {CompendiumSourcePackGroup5e}
   * @protected
   */
  _preparePackGroupContext(documentType, packs, sources) {
    packs = packs.filter(id => {
      const pack = game.packs.get(id);
      return pack.documentName === documentType;
    });
    const all = packs.isSubsetOf(sources);
    const indeterminate = !all && packs.intersects(sources);
    return {
      indeterminate,
      checked: indeterminate || all,
      entries: Array.from(packs.map(id => {
        const { collection, title } = game.packs.get(id);
        return {
          title,
          id: collection,
          checked: sources.has(id)
        };
      })).sort((a, b) => a.title.localeCompare(b.title, game.i18n.lang))
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("keydown", this._debouncedFilter, { passive: true });
  }

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

  /** @inheritDoc */
  _attachPartListeners(partId, htmlElement, options) {
    super._attachPartListeners(partId, htmlElement, options);
    if ( partId === "sidebar" ) this._filterPackages();
  }

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

  /**
   * Execute the package list filter.
   * @protected
   */
  _filterPackages() {
    const query = this.#filter.replace(/[^\p{L} ]/gu, "").toLocaleLowerCase(game.i18n.lang);
    this.element.querySelectorAll(".package-list.modules > li").forEach(item => {
      item.toggleAttribute("hidden", query && !item.dataset.filter.includes(query));
    });
  }

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

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    if ( event.target.dataset.type ) this._onToggleSource(event.target);
  }

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

  /**
   * Handle filtering the package sidebar.
   * @param {KeyboardEvent} event  The triggering event.
   * @protected
   */
  _onFilterPackages(event) {
    if ( !event.target.matches("search > input") ) return;
    this.#filter = event.target.value;
    this._filterPackages();
  }

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

  /**
   * Handle toggling a compendium browser source pack.
   * @param {CheckboxElement} target  The element that was toggled.
   * @returns {Record<string, boolean>}
   * @protected
   */
  _onTogglePack(target) {
    const packs = {};
    const { name, checked, indeterminate } = target;
    if ( (name === "all-items") || (name === "all-actors") ) {
      const [, documentType] = name.split("-");
      const pkg = this.#selected === "world"
        ? game.world
        : this.#selected === "system"
          ? game.system
          : game.modules.get(this.#selected.slice(7));
      for ( const { id, type } of pkg.packs ) {
        if ( game[documentType].documentName === type ) packs[id] = indeterminate ? false : checked;
      }
    }
    else packs[name] = checked;
    return packs;
  }

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

  /**
   * Handle toggling a compendium browser source package.
   * @param {CheckboxElement} target  The element that was toggled.
   * @returns {Record<string, boolean>}
   * @protected
   */
  _onTogglePackage(target) {
    const packs = {};
    const { name, checked, indeterminate } = target;
    const pkg = name === "world" ? game.world : name === "system" ? game.system : game.modules.get(name.slice(7));
    for ( const { id } of pkg.packs ) packs[id] = indeterminate ? false : checked;
    return packs;
  }

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

  /**
   * Toggle a compendium browser source.
   * @param {CheckboxElement} target  The element that was toggled.
   * @protected
   */
  async _onToggleSource(target) {
    let packs;
    switch ( target.dataset.type ) {
      case "pack": packs = this._onTogglePack(target); break;
      case "package": packs = this._onTogglePackage(target); break;
      default: return;
    }
    const setting = { ...game.settings.get("dnd5e", "packSourceConfiguration"), ...packs };
    await game.settings.set("dnd5e", "packSourceConfiguration", setting);
    this.render();
  }

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

  /**
   * Handle clearing the package filter.
   * @this {CompendiumBrowserSettingsConfig}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The target of the click event.
   */
  static #onClearPackageFilter(event, target) {
    const input = target.closest("search").querySelector(":scope > input");
    input.value = this.#filter = "";
    this._filterPackages();
  }

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

  /**
   * Handle selecting a package.
   * @this {CompendiumBrowserSettingsConfig}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The target of the click event.
   */
  static #onSelectPackage(event, target) {
    const { packageId } = target.closest("[data-package-id]")?.dataset ?? {};
    if ( !packageId ) return;
    this.#selected = packageId;
    this.render();
  }

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

  /**
   * Collate sources for inclusion in the compendium browser.
   * @returns {Set<string>}  The set of packs that should be included in the compendium browser.
   */
  static collateSources() {
    const sources = new Set();
    const setting = game.settings.get("dnd5e", "packSourceConfiguration");
    for ( const { collection, documentName } of game.packs ) {
      if ( (documentName !== "Actor") && (documentName !== "Item") ) continue;
      if ( setting[collection] !== false ) sources.add(collection);
    }
    return sources;
  }
}

/**
 * @typedef {ApplicationConfiguration} CompendiumBrowserConfiguration
 * @property {{locked: CompendiumBrowserFilters, initial: CompendiumBrowserFilters}} filters  Filters to set to start.
 *                                              Locked filters won't be able to be changed by the user. Initial filters
 *                                              will be set to start but can be changed.
 * @property {CompendiumBrowserSelectionConfiguration} selection  Configuration used to define document selections.
 */

/**
 * @typedef {object} CompendiumBrowserSelectionConfiguration
 * @property {number|null} min                  Minimum number of documents that must be selected.
 * @property {number|null} max                  Maximum number of documents that must be selected.
 */

/**
 * @typedef {object} CompendiumBrowserFilters
 * @property {string} [documentClass]  Document type to fetch (e.g. Actor or Item).
 * @property {Set<string>} [types]     Individual document subtypes to filter upon (e.g. "loot", "class", "npc").
 * @property {object} [additional]     Additional type-specific filters applied.
 * @property {FilterDescription[]} [arbitrary]  Additional arbitrary filters to apply, not displayed in the UI.
 *                                     Only available as part of locked filters.
 * @property {string} [name]           A substring to filter by Document name.
 */

/**
 * Filter definition object for additional filters in the Compendium Browser.
 *
 * @typedef {object} CompendiumBrowserFilterDefinitionEntry
 * @property {string} label                                   Localizable label for the filter.
 * @property {"boolean"|"range"|"set"} type                   Type of filter control to display.
 * @property {object} config                                  Type-specific configuration data.
 * @property {CompendiumBrowserCreateFilters} [createFilter]  Method that can be called to create filters.
 */

/**
 * @callback CompendiumBrowserFilterCreateFilters
 * @param {FilterDescription[]} filters                        Array of filters to be applied that should be mutated.
 * @param {*} value                                            Value of the filter.
 * @param {CompendiumBrowserFilterDefinitionEntry} definition  Definition for this filter.
 */

/**
 * @typedef {Map<string, CompendiumBrowserFilterDefinitionEntry>} CompendiumBrowserFilterDefinition
 */

/**
 * Application for browsing, filtering, and searching for content between multiple compendiums.
 * @extends Application5e
 * @template CompendiumBrowserConfiguration
 */
class CompendiumBrowser extends Application5e {
  constructor(...args) {
    super(...args);

    this.#filters = this.options.filters?.initial ?? {};

    if ( "mode" in this.options ) {
      this._mode = this.options.mode;
      this._applyModeFilters(this.options.mode);
    }

    const isAdvanced = this._mode === this.constructor.MODES.ADVANCED;
    const tab = this.constructor.TABS.find(t => t.tab === this.options.tab);
    if ( !tab || (!!tab.advanced !== isAdvanced) ) this.options.tab = isAdvanced ? "actors" : "classes";
    this._applyTabFilters(this.options.tab);
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    id: "compendium-browser-{id}",
    classes: ["compendium-browser", "vertical-tabs"],
    tag: "form",
    window: {
      title: "DND5E.CompendiumBrowser.Title",
      minimizable: true,
      resizable: true
    },
    actions: {
      configureSources: CompendiumBrowser.#onConfigureSources,
      clearName: CompendiumBrowser.#onClearName,
      openLink: CompendiumBrowser.#onOpenLink,
      setFilter: CompendiumBrowser.#onSetFilter,
      setType: CompendiumBrowser.#onSetType,
      toggleCollapse: CompendiumBrowser.#onToggleCollapse,
      toggleMode: CompendiumBrowser.#onToggleMode
    },
    form: {
      handler: CompendiumBrowser.#onHandleSubmit,
      closeOnSubmit: true
    },
    position: {
      width: 850,
      height: 700
    },
    filters: {
      locked: {},
      initial: {
        documentClass: "Item",
        types: new Set(["class"])
      }
    },
    selection: {
      min: null,
      max: null
    },
    tab: "classes"
  };

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

  /** @override */
  static PARTS = {
    header: {
      id: "header",
      classes: ["header"],
      template: "systems/dnd5e/templates/compendium/browser-header.hbs"
    },
    search: {
      id: "sidebar-search",
      classes: ["filter-element"],
      container: { id: "sidebar", classes: ["sidebar", "flexcol"] },
      template: "systems/dnd5e/templates/compendium/browser-sidebar-search.hbs"
    },
    types: {
      id: "sidebar-types",
      container: { id: "sidebar", classes: ["sidebar", "flexcol"] },
      template: "systems/dnd5e/templates/compendium/browser-sidebar-types.hbs"
    },
    filters: {
      id: "sidebar-filters",
      container: { id: "sidebar", classes: ["sidebar", "flexcol"] },
      template: "systems/dnd5e/templates/compendium/browser-sidebar-filters.hbs",
      templates: ["systems/dnd5e/templates/compendium/browser-sidebar-filter-set.hbs"]
    },
    results: {
      id: "results",
      classes: ["results"],
      template: "systems/dnd5e/templates/compendium/browser-results.hbs",
      templates: ["systems/dnd5e/templates/compendium/browser-entry.hbs"],
      scrollable: [""]
    },
    footer: {
      id: "footer",
      classes: ["footer"],
      template: "systems/dnd5e/templates/compendium/browser-footer.hbs"
    },
    tabs: {
      id: "tabs",
      classes: ["tabs", "tabs-left"],
      template: "systems/dnd5e/templates/compendium/browser-tabs.hbs"
    }
  };

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

  /**
   * @typedef {SheetTabDescriptor5e} CompendiumBrowserTabDescriptor5e
   * @property {string} documentClass  The class of Documents this tab contains.
   * @property {string[]} [types]      The sub-types of Documents this tab contains, otherwise all types of the Document
   *                                   class are assumed.
   * @property {boolean} [advanced]    Is this tab only available in the advanced browsing mode.
   */

  /**
   * Application tabs.
   * @type {CompendiumBrowserTabDescriptor5e[]}
   */
  static TABS = [
    {
      tab: "classes",
      label: "TYPES.Item.classPl",
      svg: "systems/dnd5e/icons/svg/items/class.svg",
      documentClass: "Item",
      types: ["class"]
    },
    {
      tab: "subclasses",
      label: "TYPES.Item.subclassPl",
      svg: "systems/dnd5e/icons/svg/items/subclass.svg",
      documentClass: "Item",
      types: ["subclass"]
    },
    {
      tab: "races",
      label: "TYPES.Item.racePl",
      svg: "systems/dnd5e/icons/svg/items/race.svg",
      documentClass: "Item",
      types: ["race"]
    },
    {
      tab: "feats",
      label: "DND5E.CompendiumBrowser.Tabs.Feat.other",
      icon: "fas fa-star",
      documentClass: "Item",
      types: ["feat"]
    },
    {
      tab: "backgrounds",
      label: "TYPES.Item.backgroundPl",
      svg: "systems/dnd5e/icons/svg/items/background.svg",
      documentClass: "Item",
      types: ["background"]
    },
    {
      tab: "physical",
      label: "DND5E.CompendiumBrowser.Tabs.Item.other",
      svg: "systems/dnd5e/icons/svg/backpack.svg",
      documentClass: "Item",
      types: ["physical"]
    },
    {
      tab: "spells",
      label: "TYPES.Item.spellPl",
      icon: "fas fa-book",
      documentClass: "Item",
      types: ["spell"]
    },
    {
      tab: "monsters",
      label: "DND5E.CompendiumBrowser.Tabs.Monster.other",
      svg: "systems/dnd5e/icons/svg/monster.svg",
      documentClass: "Actor",
      types: ["npc"]
    },
    {
      tab: "vehicles",
      label: "TYPES.Actor.vehiclePl",
      svg: "systems/dnd5e/icons/svg/vehicle.svg",
      documentClass: "Actor",
      types: ["vehicle"]
    },
    {
      tab: "actors",
      label: "DOCUMENT.Actors",
      svg: "systems/dnd5e/icons/svg/monster.svg",
      documentClass: "Actor",
      advanced: true
    },
    {
      tab: "items",
      label: "DOCUMENT.Items",
      svg: "systems/dnd5e/icons/svg/backpack.svg",
      documentClass: "Item",
      advanced: true
    }
  ];

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

  /**
   * Available filtering modes.
   * @enum {number}
   */
  static MODES = {
    BASIC: 1,
    ADVANCED: 2
  };

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

  /**
   * Batching configuration.
   * @type {Record<string, number>}
   */
  static BATCHING = {
    /**
     * The number of pixels before reaching the end of the scroll container to begin loading additional entries.
     */
    MARGIN: 50,

    /**
     * The number of entries to load per batch.
     */
    SIZE: 50
  };

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

  /**
   * The number of milliseconds to delay between user keypresses before executing a search.
   * @type {number}
   */
  static SEARCH_DELAY = 200;

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

  /**
   * Should the selection controls be displayed?
   * @type {boolean}
   */
  get displaySelection() {
    return !!this.options.selection.min || !!this.options.selection.max;
  }

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

  /**
   * Currently defined filters.
   */
  #filters;

  /**
   * Current filters selected.
   * @type {CompendiumBrowserFilters}
   */
  get currentFilters() {
    const filters = foundry.utils.mergeObject(
      this.#filters,
      this.options.filters.locked,
      { inplace: false }
    );
    filters.documentClass ??= "Item";
    if ( filters.additional?.source ) {
      filters.additional.source = Object.entries(filters.additional.source).reduce((obj, [k, v]) => {
        obj[k.slugify({ strict: true })] = v;
        return obj;
      }, {});
    }
    return filters;
  }

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

  /**
   * Fetched results.
   * @type {Promise<object[]|Document[]>|object[]|Document[]}
   */
  #results;

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

  /**
   * The index of the next result to render as part of batching.
   * @type {number}
   */
  #resultIndex = -1;

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

  /**
   * Whether rendering is currently throttled.
   * @type {boolean}
   */
  #renderThrottle = false;

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

  /**
   * UUIDs of currently selected documents.
   * @type {Set<string>}
   */
  #selected = new Set();

  get selected() {
    return this.#selected;
  }

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

  /**
   * Suffix used for localization selection messages based on min and max values.
   * @type {string|null}
   */
  get #selectionLocalizationSuffix() {
    const max = this.options.selection.max;
    const min = this.options.selection.min;
    if ( !min && !max ) return null;
    if ( !min && max ) return "Max";
    if ( min && !max ) return "Min";
    if ( min !== max ) return "Range";
    return "Single";
  }

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

  /**
   * The cached set of available sources to filter on.
   * @type {Record<string, string>}
   */
  #sources;

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

  /**
   * The mode the browser is currently in.
   * @type {CompendiumBrowser.MODES}
   */
  _mode = this.constructor.MODES.BASIC;

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

  /**
   * The function to invoke when searching results by name.
   * @type {Function}
   */
  _debouncedSearch = foundry.utils.debounce(this._onSearchName.bind(this), this.constructor.SEARCH_DELAY);

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

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.isFirstRender ) {
      const tab = this.constructor.TABS.find(t => t.tab === this.options.tab);
      if ( tab ) foundry.utils.setProperty(options, "dnd5e.browser.types", tab.types);
    }
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.filters = this.currentFilters;

    let dataModels = Object.entries(CONFIG[context.filters.documentClass].dataModels);
    if ( context.filters.types?.size ) dataModels = dataModels.filter(([type]) => context.filters.types.has(type));
    context.filterDefinitions = dataModels
      .map(([, d]) => d.compendiumBrowserFilters ?? new Map())
      .reduce((first, second) => {
        if ( !first ) return second;
        return CompendiumBrowser.intersectFilters(first, second);
      }, null) ?? new Map();
    context.filterDefinitions.set("source", {
      label: "DND5E.SOURCE.FIELDS.source.label",
      type: "set",
      config: {
        keyPath: "system.source.slug",
        choices: foundry.utils.mergeObject(
          this.#sources ?? {},
          Object.fromEntries(Object.keys(this.options.filters?.locked?.additional?.source ?? {}).map(k => {
            return [k.slugify({ strict: true }), CONFIG.DND5E.sourceBooks[k] ?? k];
          })), { inplace: false }
        )
      }
    });
    return context;
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "documentClass":
      case "types":
      case "filters": return this._prepareSidebarContext(partId, context, options);
      case "results": return this._prepareResultsContext(context, options);
      case "footer": return this._prepareFooterContext(context, options);
      case "tabs": return this._prepareTabsContext(context, options);
      case "header": return this._prepareHeaderContext(context, options);
    }
    return context;
  }

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

  /**
   * Prepare the footer context.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}  Context data for a specific part.
   * @protected
   */
  async _prepareFooterContext(context, options) {
    const value = this.#selected.size;
    const { max, min } = this.options.selection;

    context.displaySelection = this.displaySelection;
    context.invalid = (value < (min || -Infinity)) || (value > (max || Infinity)) ? "invalid" : "";
    const suffix = this.#selectionLocalizationSuffix;
    context.summary = suffix ? game.i18n.format(
      `DND5E.CompendiumBrowser.Selection.Summary.${suffix}`, { max, min, value }
    ) : value;
    return context;
  }

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

  /**
   * Prepare the header context.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareHeaderContext(context, options) {
    context.showModeToggle = foundry.utils.isEmpty(this.options.filters.locked);
    context.isAdvanced = this._mode === this.constructor.MODES.ADVANCED;
    return context;
  }

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

  /**
   * Prepare the sidebar context.
   * @param {string} partId                        The part being rendered.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}  Context data for a specific part.
   * @protected
   */
  async _prepareSidebarContext(partId, context, options) {
    context.isLocked = {};
    context.isLocked.filters = ("additional" in this.options.filters.locked);
    context.isLocked.types = ("types" in this.options.filters.locked) || context.isLocked.filters;
    context.isLocked.documentClass = ("documentClass" in this.options.filters.locked) || context.isLocked.types;
    const types = foundry.utils.getProperty(options, "dnd5e.browser.types") ?? [];

    if ( partId === "types" ) {
      context.showTypes = (types.length !== 1) || (types[0] === "physical");
      context.types = CONFIG[context.filters.documentClass].documentClass.compendiumBrowserTypes({
        chosen: context.filters.types
      });

      // Special case handling for 'Items' tab in basic mode.
      if ( types[0] === "physical" ) context.types = context.types.physical.children;

      if ( context.isLocked.types ) {
        for ( const [key, value] of Object.entries(context.types) ) {
          if ( !value.children && !value.chosen ) delete context.types[key];
          else if ( value.children ) {
            for ( const [k, v] of Object.entries(value.children) ) {
              if ( !v.chosen ) delete value.children[k];
            }
            if ( foundry.utils.isEmpty(value.children) ) delete context.types[key];
          }
        }
      }
    }

    else if ( partId === "filters" ) {
      context.additional = Array.from(context.filterDefinitions?.entries() ?? []).reduce((arr, [key, data]) => {
        // Special case handling for 'Feats' tab in basic mode.
        if ( (types[0] === "feat") && ((key === "category") || (key === "subtype")) ) return arr;

        let sort = 0;
        switch ( data.type ) {
          case "boolean": sort = 1; break;
          case "range": sort = 2; break;
          case "set": sort = 3; break;
        }

        arr.push(foundry.utils.mergeObject(data, {
          key, sort,
          value: context.filters.additional?.[key],
          locked: this.options.filters.locked?.additional?.[key]
        }, { inplace: false }));
        return arr;
      }, []);

      context.additional.sort((a, b) => a.sort - b.sort);
    }

    return context;
  }

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

  /**
   * Prepare the results context.
   * @param {ApplicationRenderContext} context     Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options      Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}  Context data for a specific part.
   * @protected
   */
  async _prepareResultsContext(context, options) {
    // TODO: Determine if new set of results need to be fetched, otherwise use old results and re-sort as necessary
    // Sorting changes alone shouldn't require a re-fetch, but any change to filters will
    const filters = CompendiumBrowser.applyFilters(context.filterDefinitions, context.filters.additional);
    // Add the name & arbitrary filters
    if ( this.#filters.name?.length ) filters.push({ k: "name", o: "icontains", v: this.#filters.name });
    if ( context.filters.arbitrary?.length ) filters.push(...context.filters.arbitrary);
    this.#results = CompendiumBrowser.fetch(CONFIG[context.filters.documentClass].documentClass, {
      filters,
      types: context.filters.types,
      indexFields: new Set(["system.source"])
    });
    context.displaySelection = this.displaySelection;
    return context;
  }

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

  /**
   * Prepare the tabs context.
   * @param {ApplicationRenderContext} context  Shared context provided by _prepareContext.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareTabsContext(context, options) {
    // If we are locked to a particular filter, do not show tabs.
    if ( !foundry.utils.isEmpty(this.options.filters.locked) ) {
      context.tabs = [];
      return context;
    }

    const advanced = this._mode === this.constructor.MODES.ADVANCED;
    context.tabs = foundry.utils.deepClone(this.constructor.TABS.filter(t => !!t.advanced === advanced));
    const tab = options.isFirstRender ? this.options.tab : this.tabGroups.primary;
    const activeTab = context.tabs.find(t => t.tab === tab) ?? context.tabs[0];
    activeTab.active = true;

    return context;
  }

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

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    if ( game.user.isGM ) {
      frame.querySelector('[data-action="close"]').insertAdjacentHTML("beforebegin", `
        <button type="button" class="header-control fas fa-cog icon" data-action="configureSources"
                data-tooltip="DND5E.CompendiumBrowser.Sources.Label"
                aria-label="${game.i18n.localize("DND5E.CompendiumBrowser.Sources.Label")}"></button>
      `);
    }
    return frame;
  }

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

  /**
   * Render a single result entry.
   * @param {object|Document} entry  The entry.
   * @param {string} documentClass   The entry's Document class.
   * @returns {Promise<HTMLElement>}
   * @protected
   */
  async _renderResult(entry, documentClass) {
    const { img, name, type, uuid, system } = entry;
    // TODO: Provide more useful subtitles.
    const subtitle = CONFIG[documentClass].typeLabels[type] ?? "";
    const source = system?.source?.value ?? "";
    const context = {
      entry: { img, name, subtitle, uuid, source },
      displaySelection: this.displaySelection,
      selected: this.#selected.has(uuid)
    };
    const html = await renderTemplate("systems/dnd5e/templates/compendium/browser-entry.hbs", context);
    const template = document.createElement("template");
    template.innerHTML = html;
    const element = template.content.firstElementChild;
    if ( documentClass !== "Item" ) return element;
    element.dataset.tooltip = `
      <section class="loading" data-uuid="${uuid}">
        <i class="fa-solid fa-spinner fa-spin-pulse" inert></i>
      </section>
    `;
    element.dataset.tooltipClass = "dnd5e2 dnd5e-tooltip item-tooltip";
    element.dataset.tooltipDirection ??= "RIGHT";
    return element;
  }

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

  /**
   * Render results once loaded to avoid holding up initial app display.
   * @protected
   */
  async _renderResults() {
    let rendered = [];
    const { documentClass } = this.currentFilters;
    const results = await this.#results;
    this.#results = results;
    const batchEnd = Math.min(this.constructor.BATCHING.SIZE, results.length);
    for ( let i = 0; i < batchEnd; i++ ) {
      rendered.push(this._renderResult(results[i], documentClass));
    }
    this.element.querySelector(".results-loading").hidden = true;
    this.element.querySelector('[data-application-part="results"] .item-list')
      .replaceChildren(...(await Promise.all(rendered)));
    this.#resultIndex = batchEnd;
  }

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

  /**
   * Show a list of applicable source filters for the available results.
   * @protected
   */
  async _renderSourceFilters() {
    const sources = [];
    for ( const result of this.#results ) {
      const source = foundry.utils.getProperty(result, "system.source");
      if ( foundry.utils.getType(source) !== "Object" ) continue;
      const { slug, value } = source;
      sources.push({ slug, value: CONFIG.DND5E.sourceBooks[value] ?? value });
    }
    sources.sort((a, b) => a.value.localeCompare(b.value, game.i18n.lang));
    this.#sources = Object.fromEntries(sources.map(({ slug, value }) => [slug, value]));
    const filters = this.element.querySelector('[data-application-part="filters"]');
    filters.querySelector('[data-filter-id="source"]')?.remove();
    if ( !sources.length ) return;
    const locked = Object.entries(this.options.filters?.locked?.additional?.source ?? {}).reduce((obj, [k, v]) => {
      obj[k.slugify({ strict: true })] = v;
      return obj;
    }, {});
    const filter = await renderTemplate("systems/dnd5e/templates/compendium/browser-sidebar-filter-set.hbs", {
      locked,
      value: locked,
      key: "source",
      label: "DND5E.SOURCE.FIELDS.source.label",
      config: { choices: this.#sources }
    });
    filters.insertAdjacentHTML("beforeend", filter);
  }

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

  /** @inheritDoc */
  changeTab(tab, group, options={}) {
    super.changeTab(tab, group, options);
    const target = this.element.querySelector(`nav.tabs [data-group="${group}"][data-tab="${tab}"]`);
    let { types } = target.dataset;
    types = types ? types.split(",") : [];
    this._applyTabFilters(tab);
    this.render({ parts: ["results", "filters", "types"], dnd5e: { browser: { types } }, changedTab: true });
  }

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

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("scroll", this._onScrollResults.bind(this), { capture: true, passive: true });
    this.element.addEventListener("dragstart", this._onDragStart.bind(this));
    this.element.addEventListener("keydown", this._debouncedSearch, { passive: true });
    this.element.addEventListener("keydown", this._onKeyAction.bind(this), { passive: true });
    this.element.addEventListener("pointerdown", event => {
      if ( (event.button === 1) && document.getElementById("tooltip")?.classList.contains("active") ) {
        event.preventDefault();
      }
    });
  }

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

  /** @inheritDoc */
  _attachPartListeners(partId, htmlElement, options) {
    super._attachPartListeners(partId, htmlElement, options);
    if ( partId === "results" ) this._renderResults().then(() => {
      if ( options.isFirstRender || options.changedTab ) this._renderSourceFilters();
    });
    else if ( partId === "types" ) this.#adjustCheckboxStates(htmlElement);
  }

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

  /**
   * Apply filters based on the compendium browser's mode.
   * @param {CompendiumBrowser.MODES} mode  The mode.
   * @protected
   */
  _applyModeFilters(mode) {
    const isAdvanced = mode === this.constructor.MODES.ADVANCED;
    delete this.#filters.types;
    delete this.#filters.additional;
    if ( isAdvanced ) this.#filters.documentClass = "Actor";
    else {
      this.#filters.documentClass = "Item";
      this.#filters.types = new Set(["class"]);
    }
  }

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

  /**
   * Apply filters based on the selected tab.
   * @param {string} id  The tab ID.
   * @protected
   */
  _applyTabFilters(id) {
    const tab = this.constructor.TABS.find(t => t.tab === id);
    if ( !tab ) return;
    const { documentClass, types } = tab;
    delete this.#filters.additional;
    this.#filters.documentClass = documentClass;
    this.#filters.types = new Set(types);

    // Special case handling for 'Items' tab in basic mode.
    if ( id === "physical" ) {
      const physical = Item.implementation.compendiumBrowserTypes().physical.children;
      Object.keys(physical).forEach(this.#filters.types.add, this.#filters.types);
    }

    // Special case handling for 'Feats' tab in basic mode.
    if ( id === "feats" ) this.#filters.additional = { category: { feat: 1 } };
  }

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

  /**
   * Adjust the states of group checkboxes to make then indeterminate if only some of their children are selected.
   * @param {HTMLElement} htmlElement  Element within which to find groups.
   */
  #adjustCheckboxStates(htmlElement) {
    for ( const groupArea of htmlElement.querySelectorAll(".type-group") ) {
      const group = groupArea.querySelector(".type-group-header dnd5e-checkbox");
      const children = groupArea.querySelectorAll(".wrapper dnd5e-checkbox");
      if ( Array.from(children).every(e => e.checked) ) {
        group.checked = true;
        group.indeterminate = false;
      } else {
        group.checked = group.indeterminate = Array.from(children).some(e => e.checked);
      }
    }
  }

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

  /** @override */
  _onChangeForm(formConfig, event) {
    if ( event.target.name === "selected" ) {
      if ( event.target.checked ) this.#selected.add(event.target.value);
      else this.#selected.delete(event.target.value);
      event.target.closest(".item").classList.toggle("selected", event.target.checked);
      this.render({ parts: ["footer"] });
    }
    if ( event.target.name?.startsWith("additional.") ) CompendiumBrowser.#onSetFilter.call(this, event, event.target);
  }

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

  /**
   * Handle dragging an entry.
   * @param {DragEvent} event  The drag event.
   * @protected
   */
  _onDragStart(event) {
    const { uuid } = event.target.closest("[data-uuid]")?.dataset ?? {};
    try {
      const { type } = foundry.utils.parseUuid(uuid);
      event.dataTransfer.setData("text/plain", JSON.stringify({ type, uuid }));
    } catch(e) {
      console.error(e);
    }
  }

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

  /**
   * Handle triggering an action via keyboard.
   * @param {KeyboardEvent} event  The originating event.
   * @protected
   */
  _onKeyAction(event) {
    const target = event.target.closest("[data-action]");
    if ( (event.key !== " ") || !target ) return;
    const { action } = target.dataset;
    const handler = this.options.actions[action];
    if ( handler ) handler.call(this, event, target);
  }

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

  /**
   * Handle rendering a new batch of results when the user scrolls to the bottom of the list.
   * @param {Event} event  The originating scroll event.
   * @protected
   */
  async _onScrollResults(event) {
    if ( this.#renderThrottle || !event.target.matches('[data-application-part="results"]') ) return;
    if ( (this.#results instanceof Promise) || (this.#resultIndex >= this.#results.length) ) return;
    const { scrollTop, scrollHeight, clientHeight } = event.target;
    if ( scrollTop + clientHeight < scrollHeight - this.constructor.BATCHING.MARGIN ) return;
    this.#renderThrottle = true;
    const { documentClass } = this.currentFilters;
    const rendered = [];
    const batchStart = this.#resultIndex;
    const batchEnd = Math.min(batchStart + this.constructor.BATCHING.SIZE, this.#results.length);
    for ( let i = batchStart; i < batchEnd; i++ ) {
      rendered.push(this._renderResult(this.#results[i], documentClass));
    }
    this.element.querySelector('[data-application-part="results"] .item-list').append(...(await Promise.all(rendered)));
    this.#resultIndex = batchEnd;
    this.#renderThrottle = false;
  }

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

  /**
   * Handle searching for a Document by name.
   * @param {KeyboardEvent} event  The triggering event.
   * @protected
   */
  _onSearchName(event) {
    if ( !event.target.matches("search > input") ) return;
    this.#filters.name = event.target.value;
    this.render({ parts: ["results"] });
  }

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

  /**
   * Handle configuring compendium browser sources.
   * @this {CompendiumBrowser}
   */
  static #onConfigureSources() {
    new CompendiumBrowserSettingsConfig().render({ force: true });
  }

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

  /**
   * Handle clearing the name filter.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The target of the click event.
   */
  static async #onClearName(event, target) {
    const input = target.closest("search").querySelector(":scope > input");
    input.value = this.#filters.name = "";
    this.render({ parts: ["results"] });
  }

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

  /**
   * Handle form submission with selection.
   * @this {CompendiumBrowser}
   * @param {SubmitEvent} event          The form submission event.
   * @param {HTMLFormElement} form       The submitted form element.
   * @param {FormDataExtended} formData  The data from the submitted form.
   */
  static async #onHandleSubmit(event, form, formData) {
    if ( !this.displaySelection ) return;

    const value = this.#selected.size;
    const { max, min } = this.options.selection;
    if ( (value < (min || -Infinity)) || (value > (max || Infinity)) ) {
      const suffix = this.#selectionLocalizationSuffix;
      const pr = new Intl.PluralRules(game.i18n.lang);
      throw new Error(game.i18n.format(`DND5E.CompendiumBrowser.Selection.Warning.${suffix}`, {
        max, min, value,
        document: game.i18n.localize(`DND5E.CompendiumBrowser.Selection.Warning.Document.${pr.select(max || min)}`)
      }));
    }

    /**
     * Hook event that calls when a compendium browser is submitted with selected items.
     * @function dnd5e.compendiumBrowserSelection
     * @memberof hookEvents
     * @param {CompendiumBrowser} browser  Compendium Browser application being submitted.
     * @param {Set<string>} selected       Set of document UUIDs that are selected.
     */
    Hooks.callAll("dnd5e.compendiumBrowserSelection", this, this.#selected);
  }

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

  /**
   * Handle opening a link to an item.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The capturing HTML element which defined a [data-action].
   */
  static async #onOpenLink(event, target) {
    (await fromUuid(target.closest("[data-uuid]")?.dataset.uuid))?.sheet?.render(true);
  }

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

  /**
   * Handle setting the document class or a filter.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The capturing HTML element which defined a [data-action].
   */
  static async #onSetFilter(event, target) {
    const name = target.name;
    const value = target.value;
    const existingValue = foundry.utils.getProperty(this.#filters, name);
    if ( value === existingValue ) return;
    foundry.utils.setProperty(this.#filters, name, value === "" ? undefined : value);

    if ( target.tagName === "BUTTON" ) for ( const button of this.element.querySelectorAll(`[name="${name}"]`) ) {
      button.ariaPressed = button.value === value;
    }

    this.render({ parts: ["results"] });
  }

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

  /**
   * Handle setting a type restriction.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The capturing HTML element which defined a [data-action].
   */
  static async #onSetType(event, target) {
    this.#filters.types ??= new Set();

    if ( target.defaultValue ) {
      if ( target.checked ) this.#filters.types.add(target.defaultValue);
      else this.#filters.types.delete(target.defaultValue);
      this.#adjustCheckboxStates(target.closest(".sidebar"));
    }

    else {
      target.indeterminate = false;
      for ( const child of target.closest(".type-group").querySelectorAll("dnd5e-checkbox[value]") ) {
        child.checked = target.checked;
        if ( target.checked ) this.#filters.types.add(child.defaultValue);
        else this.#filters.types.delete(child.defaultValue);
      }
    }

    this.render({ parts: ["filters", "results"] });
  }

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

  /**
   * Handle toggling the collapsed state of a collapsible section.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The capturing HTML element which defined a [data-action].
   */
  static async #onToggleCollapse(event, target) {
    target.closest(".collapsible")?.classList.toggle("collapsed");
  }

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

  /**
   * Handle toggling the compendium browser mode.
   * @this {CompendiumBrowser}
   * @param {PointerEvent} event  The originating click event.
   * @param {HTMLElement} target  The element that was clicked.
   */
  static #onToggleMode(event, target) {
    // TODO: Consider persisting this choice in a client setting.
    this._mode = target.checked ? this.constructor.MODES.ADVANCED : this.constructor.MODES.BASIC;
    const tabs = foundry.utils.deepClone(this.constructor.TABS.filter(t => !!t.advanced === target.checked));
    const activeTab = tabs.find(t => t.tab === this.tabGroups.primary) ?? tabs[0];
    const types = target.checked ? [] : (activeTab?.types ?? ["class"]);
    this._applyModeFilters(this._mode);
    this._applyTabFilters(activeTab?.tab);
    this.render({ parts: ["results", "filters", "types", "tabs"], dnd5e: { browser: { types } }, changedTab: true });
  }

  /* -------------------------------------------- */
  /*  Database Access                             */
  /* -------------------------------------------- */

  /**
   * Retrieve a listing of documents from all compendiums for a specific Document type, with additional filters
   * optionally applied.
   * @param {typeof Document} documentClass  Document type to fetch (e.g. Actor or Item).
   * @param {object} [options={}]
   * @param {Set<string>} [options.types]    Individual document subtypes to filter upon (e.g. "loot", "class", "npc").
   * @param {FilterDescription[]} [options.filters]  Filters to provide further filters.
   * @param {boolean} [options.index=true]   Should only the index for each document be returned, or the whole thing?
   * @param {Set<string>} [options.indexFields]  Key paths for fields to index.
   * @param {boolean|string|Function} [options.sort=true]  Should the contents be sorted? By default sorting will be
   *                                         performed using document names, but a key path can be provided to sort on
   *                                         a specific property or a function to provide more advanced sorting.
   * @returns {object[]|Document[]}
   */
  static async fetch(documentClass, { types=new Set(), filters=[], index=true, indexFields=new Set(), sort=true }={}) {
    // Nothing within containers should be shown
    filters.push({ k: "system.container", o: "in", v: [null, undefined] });

    // If filters are provided, merge their keys with any other fields needing to be indexed
    if ( filters.length ) indexFields = indexFields.union(uniqueKeys(filters));

    // Do not attempt to index derived fields as this will throw an error server-side.
    indexFields.delete("system.source.slug");

    // Collate compendium sources.
    const sources = CompendiumBrowserSettingsConfig.collateSources();

    // Iterate over all packs
    let documents = game.packs

      // Skip packs that have the wrong document class
      .filter(p => (p.metadata.type === documentClass.metadata.name)

        // Do not show entries inside compendia that are not visible to the current user.
        && p.visible

        && sources.has(p.collection)

        // If types are set and specified in compendium flag, only include those that include the correct types
        && (!types.size || !p.metadata.flags.dnd5e?.types || new Set(p.metadata.flags.dnd5e.types).intersects(types)))

      // Generate an index based on the needed fields
      .map(async p => await Promise.all((await p.getIndex({ fields: Array.from(indexFields) })

        // Apply module art to the new index
        .then(index => game.dnd5e.moduleArt.apply(index)))

        // Derive source values
        .map(i => {
          const source = foundry.utils.getProperty(i, "system.source");
          if ( (foundry.utils.getType(source) === "Object") && i.uuid ) SourceField.prepareData.call(source, i.uuid);
          return i;
        })

        // Remove any documents that don't match the specified types or the provided filters
        .filter(i => (!types.size || types.has(i.type)) && (!filters.length || performCheck(i, filters)))

        // If full documents are required, retrieve those, otherwise stick with the indices
        .map(async i => index ? i : await fromUuid(i.uuid))
      ));

    // Wait for everything to finish loading and flatten the arrays
    documents = (await Promise.all(documents)).flat();

    if ( sort ) {
      if ( sort === true ) sort = "name";
      const sortFunc = foundry.utils.getType(sort) === "function" ? sort : (lhs, rhs) =>
        String(foundry.utils.getProperty(lhs, sort))
          .localeCompare(String(foundry.utils.getProperty(rhs, sort)), game.i18n.lang);
      documents.sort(sortFunc);
    }

    return documents;
  }

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

  /**
   * Factory method used to spawn a compendium browser and wait for the results of a selection.
   * @param {Partial<CompendiumBrowserConfiguration>} [options]
   * @returns {Promise<Set<string>|null>}
   */
  static async select(options={}) {
    return new Promise((resolve, reject) => {
      const browser = new CompendiumBrowser(options);
      browser.addEventListener("close", event => {
        resolve(browser.selected?.size ? browser.selected : null);
      }, { once: true });
      browser.render({ force: true });
    });
  }

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

  /**
   * Factory method used to spawn a compendium browser and return a single selected item or null if canceled.
   * @param {Partial<CompendiumBrowserConfiguration>} [options]
   * @returns {Promise<string|null>}
   */
  static async selectOne(options={}) {
    const result = await this.select(
      foundry.utils.mergeObject(options, { selection: { min: 1, max: 1 } }, { inplace: false })
    );
    return result?.size ? result.first() : null;
  }

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

  /**
   * Transform filter definition and additional filters values into the final filters to apply.
   * @param {CompendiumBrowserFilterDefinition} definition  Filter definition provided by type.
   * @param {object} values                                 Values of currently selected filters.
   * @returns {FilterDescription[]}
   */
  static applyFilters(definition, values) {
    const filters = [];
    for ( const [key, value] of Object.entries(values ?? {}) ) {
      const def = definition.get(key);
      if ( !def ) continue;
      if ( foundry.utils.getType(def.createFilter) === "function" ) {
        def.createFilter(filters, value, def);
        continue;
      }
      switch ( def.type ) {
        case "boolean":
          if ( value ) filters.push({ k: def.config.keyPath, v: value === 1 });
          break;
        case "range":
          const min = Number(value.min);
          const max = Number(value.max);
          if ( Number.isFinite(min) ) filters.push({ k: def.config.keyPath, o: "gte", v: min });
          if ( Number.isFinite(max) ) filters.push({ k: def.config.keyPath, o: "lte", v: max });
          break;
        case "set":
          const choices = foundry.utils.deepClone(def.config.choices);
          if ( def.config.blank ) choices._blank = "";
          const [positive, negative] = Object.entries(value ?? {}).reduce(([positive, negative], [k, v]) => {
            if ( k in choices ) {
              if ( k === "_blank" ) k = "";
              if ( v === 1 ) positive.push(k);
              else if ( v === -1 ) negative.push(k);
            }
            return [positive, negative];
          }, [[], []]);
          if ( positive.length ) filters.push(
            { k: def.config.keyPath, o: def.config.multiple ? "hasall" : "in", v: positive }
          );
          if ( negative.length ) filters.push(
            { o: "NOT", v: { k: def.config.keyPath, o: def.config.multiple ? "hasany" : "in", v: negative } }
          );
          break;
        default:
          console.warn(`Filter type ${def.type} not handled.`);
          break;
      }
    }
    return filters;
  }

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

  /**
   * Inject the compendium browser button into the compendium sidebar.
   * @param {HTMLElement} html  HTML of the sidebar being rendered.
   */
  static injectSidebarButton(html) {
    const button = document.createElement("button");
    button.type = "button";
    button.classList.add("open-compendium-browser");
    button.innerHTML = `
      <i class="fa-solid fa-book-open-reader" inert></i>
      ${game.i18n.localize("DND5E.CompendiumBrowser.Action.Open")}
    `;
    button.addEventListener("click", event => (new CompendiumBrowser()).render({ force: true }));

    let headerActions = html.querySelector(".header-actions");
    // FIXME: Workaround for 336 bug. Remove when 337 released.
    if ( !headerActions ) {
      headerActions = document.createElement("div");
      headerActions.className = "header-actions action-buttons flexrow";
      html.querySelector(":scope > header").insertAdjacentElement("afterbegin", headerActions);
    }
    headerActions.append(button);
  }

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

  /**
   * Take two filter sets and find only the filters that match between the two.
   * @param {CompendiumBrowserFilterDefinition} first
   * @param {CompendiumBrowserFilterDefinition>} second
   * @returns {CompendiumBrowserFilterDefinition}
   */
  static intersectFilters(first, second) {
    const final = new Map();

    // Iterate over all keys in first map
    for ( const [key, firstConfig] of first.entries() ) {
      const secondConfig = second.get(key);
      if ( firstConfig.type !== secondConfig?.type ) continue;
      const finalConfig = foundry.utils.deepClone(firstConfig);

      switch ( firstConfig.type ) {
        case "range":
          if ( ("min" in firstConfig.config) || ("min" in secondConfig.config) ) {
            if ( !("min" in firstConfig.config) || !("min" in secondConfig.config) ) continue;
            finalConfig.config.min = Math.max(firstConfig.config.min, secondConfig.config.min);
          }
          if ( ("max" in firstConfig.config) || ("max" in secondConfig.config) ) {
            if ( !("max" in firstConfig.config) || !("max" in secondConfig.config) ) continue;
            finalConfig.config.max = Math.min(firstConfig.config.max, secondConfig.config.max);
          }
          if ( ("min" in finalConfig.config) && ("max" in finalConfig.config)
            && (finalConfig.config.min > finalConfig.config.max) ) continue;
          break;
        case "set":
          Object.keys(finalConfig.config.choices).forEach(k => {
            if ( !(k in secondConfig.config.choices) ) delete finalConfig.config.choices[k];
          });
          if ( foundry.utils.isEmpty(finalConfig.config.choices) ) continue;
          break;
      }

      final.set(key, finalConfig);
    }
    return final;
  }
}

/**
 * Configuration information for a token placement operation.
 *
 * @typedef {object} TokenPlacementConfiguration
 * @property {PrototypeToken[]} tokens  Prototype token information for rendering.
 */

/**
 * Data for token placement on the scene.
 *
 * @typedef {object} PlacementData
 * @property {PrototypeToken} prototypeToken
 * @property {object} index
 * @property {number} index.total             Index of the placement across all placements.
 * @property {number} index.unique            Index of the placement across placements with the same original token.
 * @property {number} x
 * @property {number} y
 * @property {number} rotation
 */

/**
 * Class responsible for placing one or more tokens onto the scene.
 * @param {TokenPlacementConfiguration} config  Configuration information for placement.
 */
class TokenPlacement {
  constructor(config) {
    this.config = config;
  }

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

  /**
   * Configuration information for the placements.
   * @type {TokenPlacementConfiguration}
   */
  config;

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

  /**
   * Index of the token configuration currently being placed in the scene.
   * @param {number}
   */
  #currentPlacement = -1;

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

  /**
   * Track the bound event handlers so they can be properly canceled later.
   * @type {object}
   */
  #events;

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

  /**
   * Track the timestamp when the last mouse move event was captured.
   * @type {number}
   */
  #moveTime = 0;

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

  /**
   * Placements that have been generated.
   * @type {PlacementData[]}
   */
  #placements;

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

  /**
   * Preview tokens. Should match 1-to-1 with placements.
   * @type {Token[]}
   */
  #previews;

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

  /**
   * Is the system currently being throttled to the next animation frame?
   * @type {boolean}
   */
  #throttle = false;

  /* -------------------------------------------- */
  /*  Placement                                   */
  /* -------------------------------------------- */

  /**
   * Perform the placement, asking player guidance when necessary.
   * @param {TokenPlacementConfiguration} config
   * @returns {Promise<PlacementData[]>}
   */
  static place(config) {
    const placement = new this(config);
    return placement.place();
  }

  /**
   * Perform the placement, asking player guidance when necessary.
   * @returns {Promise<PlacementData[]>}
   */
  async place() {
    this.#createPreviews();
    try {
      const placements = [];
      let total = 0;
      const uniqueTokens = new Map();
      while ( this.#currentPlacement < this.config.tokens.length - 1 ) {
        this.#currentPlacement++;
        const obj = canvas.tokens.preview.addChild(this.#previews[this.#currentPlacement].object);
        await obj.draw();
        obj.eventMode = "none";
        const placement = await this.#requestPlacement();
        if ( placement ) {
          const actorId = placement.prototypeToken.parent.id;
          uniqueTokens.set(actorId, (uniqueTokens.get(actorId) ?? -1) + 1);
          placement.index = { total: total++, unique: uniqueTokens.get(actorId) };
          placements.push(placement);
        } else obj.clear();
      }
      return placements;
    } finally {
      this.#destroyPreviews();
    }
  }

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

  /**
   * Create token previews based on the prototype tokens in config.
   */
  #createPreviews() {
    this.#placements = [];
    this.#previews = [];
    for ( const prototypeToken of this.config.tokens ) {
      const tokenData = prototypeToken.toObject();
      tokenData.sight.enabled = false;
      tokenData._id = foundry.utils.randomID();
      if ( tokenData.randomImg ) tokenData.texture.src = prototypeToken.actor.img;
      const cls = getDocumentClass("Token");
      const doc = new cls(tokenData, { parent: canvas.scene });
      this.#placements.push({ prototypeToken, x: 0, y: 0, rotation: tokenData.rotation ?? 0 });
      this.#previews.push(doc);
    }
  }

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

  /**
   * Clear any previews from the scene.
   */
  #destroyPreviews() {
    this.#previews.forEach(p => p.object.destroy());
  }

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

  /**
   * Activate listeners for the placement preview.
   * @returns {Promise<PlacementData|false>}  A promise that resolves with the final placement if created,
   *                                          or false if the placement was skipped.
   */
  #requestPlacement() {
    return new Promise((resolve, reject) => {
      this.#events = {
        confirm: this.#onConfirmPlacement.bind(this),
        move: this.#onMovePlacement.bind(this),
        resolve,
        reject,
        rotate: this.#onRotatePlacement.bind(this),
        skip: this.#onSkipPlacement.bind(this)
      };

      // Activate listeners
      canvas.stage.on("mousemove", this.#events.move);
      canvas.stage.on("mousedown", this.#events.confirm);
      canvas.app.view.oncontextmenu = this.#events.skip;
      canvas.app.view.onwheel = this.#events.rotate;
    });
  }

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

  /**
   * Shared code for when token placement ends by being confirmed or canceled.
   * @param {Event} event  Triggering event that ended the placement.
   */
  async #finishPlacement(event) {
    canvas.stage.off("mousemove", this.#events.move);
    canvas.stage.off("mousedown", this.#events.confirm);
    canvas.app.view.oncontextmenu = null;
    canvas.app.view.onwheel = null;
  }

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

  /**
   * Move the token preview when the mouse moves.
   * @param {Event} event  Triggering mouse event.
   */
  #onMovePlacement(event) {
    event.stopPropagation();
    if ( this.#throttle ) return;
    this.#throttle = true;
    const idx = this.#currentPlacement;
    const preview = this.#previews[idx];
    const clone = preview.object;
    const local = event.data.getLocalPosition(canvas.tokens);
    local.x = local.x - (clone.w / 2);
    local.y = local.y - (clone.h / 2);
    const dest = !event.shiftKey ? clone.getSnappedPosition(local) : local;
    preview.updateSource({x: dest.x, y: dest.y});
    this.#placements[idx].x = preview.x;
    this.#placements[idx].y = preview.y;
    canvas.tokens.preview.children[this.#currentPlacement]?.refresh();
    requestAnimationFrame(() => this.#throttle = false);
  }

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

  /**
   * Rotate the token preview by 3˚ increments when the mouse wheel is rotated.
   * @param {Event} event  Triggering mouse event.
   */
  #onRotatePlacement(event) {
    if ( event.ctrlKey ) event.preventDefault(); // Avoid zooming the browser window
    event.stopPropagation();
    const delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15;
    const snap = event.shiftKey ? delta : 5;
    const preview = this.#previews[this.#currentPlacement];
    this.#placements[this.#currentPlacement].rotation += snap * Math.sign(event.deltaY);
    preview.updateSource({ rotation: this.#placements[this.#currentPlacement].rotation });
    canvas.tokens.preview.children[this.#currentPlacement]?.refresh();
  }

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

  /**
   * Confirm placement when the left mouse button is clicked.
   * @param {Event} event  Triggering mouse event.
   */
  async #onConfirmPlacement(event) {
    await this.#finishPlacement(event);
    this.#events.resolve(this.#placements[this.#currentPlacement]);
  }

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

  /**
   * Skip placement when the right mouse button is clicked.
   * @param {Event} event  Triggering mouse event.
   */
  async #onSkipPlacement(event) {
    await this.#finishPlacement(event);
    this.#events.resolve(false);
  }

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

  /**
   * Adjust the appended number on an unlinked token to account for multiple placements.
   * @param {TokenDocument|object} tokenDocument  Document or data object to adjust.
   * @param {PlacementData} placement             Placement data associated with this token document.
   */
  static adjustAppendedNumber(tokenDocument, placement) {
    const regex = new RegExp(/\((\d+)\)$/);
    const match = tokenDocument.name?.match(regex);
    if ( !match ) return;
    const name = tokenDocument.name.replace(regex, `(${Number(match[1]) + placement.index.unique})`);
    if ( tokenDocument instanceof TokenDocument ) tokenDocument.updateSource({ name });
    else tokenDocument.name = name;
  }
}

const {
  ArrayField: ArrayField$i, BooleanField: BooleanField$x, DocumentIdField: DocumentIdField$5, NumberField: NumberField$B, SchemaField: SchemaField$F, SetField: SetField$r, StringField: StringField$V
} = foundry.data.fields;

/**
 * Information for a single summoned creature.
 *
 * @typedef {object} SummonsProfile
 * @property {string} _id         Unique ID for this profile.
 * @property {string} count       Formula for the number of creatures to summon.
 * @property {string} cr          Formula for the CR of summoned creatures if in CR mode.
 * @property {object} level
 * @property {number} level.min   Minimum level at which this profile can be used.
 * @property {number} level.max   Maximum level at which this profile can be used.
 * @property {string} name        Display name for this profile if it differs from actor's name.
 * @property {Set<string>} types  Types of summoned creatures if in CR mode.
 * @property {string} uuid        UUID of the actor to summon if in default mode.
 */

/**
 * Data model for a summon activity.
 *
 * @property {object} bonuses
 * @property {string} bonuses.ac            Formula for armor class bonus on summoned actor.
 * @property {string} bonuses.hd            Formula for bonus hit dice to add to each summoned NPC.
 * @property {string} bonuses.hp            Formula for bonus hit points to add to each summoned actor.
 * @property {string} bonuses.attackDamage  Formula for bonus added to damage for attacks.
 * @property {string} bonuses.saveDamage    Formula for bonus added to damage for saving throws.
 * @property {string} bonuses.healing       Formula for bonus added to healing.
 * @property {Set<string>} creatureSizes    Set of creature sizes that will be set on summoned creature.
 * @property {Set<string>} creatureTypes    Set of creature types that will be set on summoned creature.
 * @property {object} match
 * @property {string} match.ability         Ability to use for calculating match values.
 * @property {boolean} match.attacks        Match the to hit values on summoned actor's attack to the summoner.
 * @property {boolean} match.proficiency    Match proficiency on summoned actor to the summoner.
 * @property {boolean} match.saves          Match the save DC on summoned actor's abilities to the summoner.
 * @property {SummonsProfile[]} profiles    Information on creatures that can be summoned.
 * @property {object} summon
 * @property {string} summon.identifier     Class identifier that will be used to determine applicable level.
 * @property {""|"cr"} summon.mode          Method of determining what type of creature is summoned.
 * @property {boolean} summon.prompt        Should the player be prompted to place the summons?
 */
class SummonActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      bonuses: new SchemaField$F({
        ac: new FormulaField(),
        hd: new FormulaField(),
        hp: new FormulaField(),
        attackDamage: new FormulaField(),
        saveDamage: new FormulaField(),
        healing: new FormulaField()
      }),
      creatureSizes: new SetField$r(new StringField$V()),
      creatureTypes: new SetField$r(new StringField$V()),
      match: new SchemaField$F({
        ability: new StringField$V(),
        attacks: new BooleanField$x(),
        proficiency: new BooleanField$x(),
        saves: new BooleanField$x()
      }),
      profiles: new ArrayField$i(new SchemaField$F({
        _id: new DocumentIdField$5({ initial: () => foundry.utils.randomID() }),
        count: new FormulaField(),
        cr: new FormulaField({ deterministic: true }),
        level: new SchemaField$F({
          min: new NumberField$B({ integer: true, min: 0 }),
          max: new NumberField$B({ integer: true, min: 0 })
        }),
        name: new StringField$V(),
        types: new SetField$r(new StringField$V()),
        uuid: new StringField$V()
      })),
      summon: new SchemaField$F({
        identifier: new IdentifierField(),
        mode: new StringField$V(),
        prompt: new BooleanField$x({ initial: true })
      })
    };
  }

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

  /** @inheritDoc */
  get ability() {
    return this.match.ability || super.ability;
  }

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

  /** @override */
  get actionType() {
    return "summ";
  }

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

  /** @override */
  get applicableEffects() {
    return null;
  }

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

  /**
   * Summons that can be performed based on spell/character/class level.
   * @type {SummonsProfile[]}
   */
  get availableProfiles() {
    const level = this.relevantLevel;
    return this.profiles.filter(e => ((e.level.min ?? -Infinity) <= level) && (level <= (e.level.max ?? Infinity)));
  }

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

  /**
   * Determine the level used to determine profile limits, based on the spell level for spells or either the
   * character or class level, depending on whether `classIdentifier` is set.
   * @type {number}
   */
  get relevantLevel() {
    const keyPath = (this.item.type === "spell") && (this.item.system.level > 0) ? "item.level"
      : this.summon.identifier ? `classes.${this.summon.identifier}.levels` : "details.level";
    return foundry.utils.getProperty(this.getRollData(), keyPath) ?? 0;
  }

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

  /**
   * Creatures summoned by this activity.
   * @type {Actor5e[]}
   */
  get summonedCreatures() {
    if ( !this.actor ) return [];
    return dnd5e.registry.summons.creatures(this.actor)
      .filter(i => i?.getFlag("dnd5e", "summon.origin") === this.uuid);
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      bonuses: source.system.summons?.bonuses ?? {},
      creatureSizes: source.system.summons?.creatureSizes ?? [],
      creatureTypes: source.system.summons?.creatureTypes ?? [],
      match: {
        ...(source.system.summons?.match ?? {}),
        ability: source.system.ability
      },
      profiles: source.system.summons?.profiles ?? [],
      summon: {
        identifier: source.system.summons?.classIdentifier ?? "",
        mode: source.system.summons?.mode ?? "",
        prompt: source.system.summons?.prompt ?? true
      }
    });
  }
}

/**
 * Activity for summoning creatures.
 */
class SummonActivity extends ActivityMixin(SummonActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.SUMMON"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "summon",
      img: "systems/dnd5e/icons/svg/activity/summon.svg",
      title: "DND5E.SUMMON.Title",
      sheetClass: SummonSheet,
      usage: {
        actions: {
          placeSummons: SummonActivity.#placeSummons
        },
        dialog: SummonUsageDialog
      }
    }, { inplace: false })
  );

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

  /** @inheritDoc */
  static localize() {
    super.localize();
    this._localizeSchema(this.schema.fields.profiles.element, ["DND5E.SUMMON.FIELDS.profiles"]);
  }

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

  /**
   * Does the user have permissions to summon?
   * @type {boolean}
   */
  get canSummon() {
    return game.user.can("TOKEN_CREATE") && (game.user.isGM || game.settings.get("dnd5e", "allowSummoning"));
  }

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /**
   * @typedef {ActivityUseConfiguration} SummonUseConfiguration
   * @property {object|false} create
   * @property {string} create.summons                    Should a summoned creature be created?
   * @property {Partial<SummoningConfiguration>} summons  Options for configuring summoning behavior.
   */

  /**
   * Configuration data for summoning behavior.
   *
   * @typedef {object} SummoningConfiguration
   * @property {string} profile         ID of the summoning profile to use.
   * @property {string} [creatureSize]  Selected creature size if multiple are available.
   * @property {string} [creatureType]  Selected creature type if multiple are available.
   */

  /**
   * @typedef {ActivityUsageResults} SummonUsageResults
   * @property {Token5e[]} summoned  Summoned tokens.
   */

  /** @inheritDoc */
  _createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig) {
    const config = super._createDeprecatedConfigs(usageConfig, dialogConfig, messageConfig);
    config.createSummons = usageConfig.create?.summons ?? null;
    config.summonsProfile = usageConfig.summons?.profile ?? null;
    config.summonsOptions = {
      creatureSize: usageConfig.summons?.creatureSize,
      creatureType: usageConfig.summons?.creatureType
    };
    return config;
  }

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

  /** @inheritDoc */
  _applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options) {
    super._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options);
    const set = (config, keyPath, value) => {
      if ( value === undefined ) return;
      foundry.utils.setProperty(config, keyPath, value);
    };
    set(usageConfig, "create.summons", config.createSummons);
    set(usageConfig, "summons.profile", config.summonsProfile);
    set(usageConfig, "summons.creatureSize", config.summonsOptions?.creatureSize);
    set(usageConfig, "summons.creatureType", config.summonsOptions?.creatureType);
  }

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

  /** @inheritDoc */
  _prepareUsageConfig(config) {
    config = super._prepareUsageConfig(config);
    const summons = this.availableProfiles;
    config.create ??= {};
    config.create.summons ??= this.canSummon && canvas.scene && summons.length && this.summon.prompt;
    config.summons ??= {};
    config.summons.profile ??= summons[0]?._id ?? null;
    config.summons.creatureSize ??= this.creatureSizes.first() ?? null;
    config.summons.creatureType ??= this.creatureTypes.first() ?? null;
    return config;
  }

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

  /** @override */
  _usageChatButtons(message) {
    if ( !this.availableProfiles.length ) return super._usageChatButtons(message);
    return [{
      label: game.i18n.localize("DND5E.SUMMON.Action.Summon"),
      icon: '<i class="fa-solid fa-spaghetti-monster-flying" inert></i>',
      dataset: {
        action: "placeSummons"
      }
    }].concat(super._usageChatButtons(message));
  }

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

  /** @inheritDoc */
  shouldHideChatButton(button, message) {
    if ( button.dataset.action === "placeSummons" ) return !this.canSummon;
    return super.shouldHideChatButton(button, message);
  }

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

  /** @inheritDoc */
  async _finalizeUsage(config, results) {
    await super._finalizeUsage(config, results);
    if ( config.create?.summons ) {
      try {
        results.summoned = await this.placeSummons(config.summons);
      } catch(err) {
        results.summoned = [];
        Hooks.onError("SummonActivity#use", err, { log: "error", notify: "error" });
      }
    }
  }

  /* -------------------------------------------- */
  /*  Summoning                                   */
  /* -------------------------------------------- */

  /**
   * Process for summoning actor to the scene.
   * @param {SummoningConfiguration} options  Configuration data for summoning behavior.
   * @returns {Token5e[]|void}
   */
  async placeSummons(options) {
    if ( !this.canSummon || !canvas.scene ) return;

    const profile = this.profiles.find(p => p._id === options?.profile);
    if ( !profile ) throw new Error(
      game.i18n.format("DND5E.SUMMON.Warning.NoProfile", { profileId: options.profile, item: this.item.name })
    );

    /**
     * A hook event that fires before summoning is performed.
     * @function dnd5e.preSummon
     * @memberof hookEvents
     * @param {SummonActivity} activity         The activity that is performing the summoning.
     * @param {SummonsProfile} profile          Profile used for summoning.
     * @param {SummoningConfiguration} options  Additional summoning options.
     * @returns {boolean}                       Explicitly return `false` to prevent summoning.
     */
    if ( Hooks.call("dnd5e.preSummon", this, profile, options) === false ) return;

    // Fetch the actor that will be summoned
    const summonUuid = this.summon.mode === "cr" ? await this.queryActor(profile) : profile.uuid;
    if ( !summonUuid ) return;
    const actor = await this.fetchActor(summonUuid);

    // Verify ownership of actor
    if ( !actor.isOwner ) {
      throw new Error(game.i18n.format("DND5E.SUMMON.Warning.NoOwnership", { actor: actor.name }));
    }

    const tokensData = [];
    const minimized = !this.actor?.sheet._minimized;
    await this.actor?.sheet.minimize();
    try {
      // Figure out where to place the summons
      const placements = await this.getPlacement(actor.prototypeToken, profile, options);

      for ( const placement of placements ) {
        // Prepare changes to actor data, re-calculating per-token for potentially random values
        const tokenUpdateData = {
          actor,
          placement,
          ...(await this.getChanges(actor, profile, options))
        };

        /**
         * A hook event that fires before a specific token is summoned. After placement has been determined but before
         * the final token data is constructed.
         * @function dnd5e.preSummonToken
         * @memberof hookEvents
         * @param {SummonActivity} activity         The activity that is performing the summoning.
         * @param {SummonsProfile} profile          Profile used for summoning.
         * @param {TokenUpdateData} config          Configuration for creating a modified token.
         * @param {SummoningConfiguration} options  Additional summoning options.
         * @returns {boolean}                       Explicitly return `false` to prevent this token from being summoned.
         */
        if ( Hooks.call("dnd5e.preSummonToken", this, profile, tokenUpdateData, options) === false ) continue;

        // Create a token document and apply updates
        const tokenData = await this.getTokenData(tokenUpdateData);

        /**
         * A hook event that fires after token creation data is prepared, but before summoning occurs.
         * @function dnd5e.summonToken
         * @memberof hookEvents
         * @param {SummonActivity} activity         The activity that is performing the summoning.
         * @param {SummonsProfile} profile          Profile used for summoning.
         * @param {object} tokenData                Data for creating a token.
         * @param {SummoningConfiguration} options  Additional summoning options.
         */
        Hooks.callAll("dnd5e.summonToken", this, profile, tokenData, options);

        tokensData.push(tokenData);
      }
    } finally {
      if ( minimized ) this.actor?.sheet.maximize();
    }

    const createdTokens = await canvas.scene.createEmbeddedDocuments("Token", tokensData);

    /**
     * A hook event that fires when summoning is complete.
     * @function dnd5e.postSummon
     * @memberof hookEvents
     * @param {SummonActivity} activity         The activity that is performing the summoning.
     * @param {SummonsProfile} profile          Profile used for summoning.
     * @param {Token5e[]} tokens                Tokens that have been created.
     * @param {SummoningConfiguration} options  Additional summoning options.
     */
    Hooks.callAll("dnd5e.postSummon", this, profile, createdTokens, options);

    return createdTokens;
  }

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

  /**
   * If actor to be summoned is in a compendium, create a local copy or use an already imported version if present.
   * @param {string} uuid  UUID of actor that will be summoned.
   * @returns {Actor5e}    Local copy of actor.
   */
  async fetchActor(uuid) {
    const actor = await fromUuid(uuid);
    if ( !actor ) throw new Error(game.i18n.format("DND5E.SUMMON.Warning.NoActor", { uuid }));

    const actorLink = actor.prototypeToken.actorLink;
    if ( !actor.pack && (!actorLink || actor.getFlag("dnd5e", "summon.origin") === this.item?.uuid )) return actor;

    // Search world actors to see if any usable summoned actor instances are present from prior summonings.
    // Linked actors must match the summoning origin (activity) to be considered.
    const localActor = game.actors.find(a =>
      // Has been cloned for summoning use
      a.getFlag("dnd5e", "summonedCopy")
      // Sourced from the desired actor UUID
      && (a._stats?.compendiumSource === uuid)
      // Unlinked or created from this activity's parent item specifically
      && ((a.getFlag("dnd5e", "summon.origin") === this.item?.uuid) || !a.prototypeToken.actorLink)
    );
    if ( localActor ) return localActor;

    // Check permissions to create actors before importing
    if ( !game.user.can("ACTOR_CREATE") ) throw new Error(game.i18n.localize("DND5E.SUMMON.Warning.CreateActor"));

    // No suitable world actor was found, create a new actor for this summoning instance.
    if ( actor.pack ) {
      // Template actor resides only in compendium, import the actor into the world and set the flag.
      return game.actors.importFromCompendium(game.packs.get(actor.pack), actor.id, {
        "flags.dnd5e.summonedCopy": true
      });
    } else {
      // Template actor (linked) found in world, create a copy for this user's item.
      return actor.clone({
        "flags.dnd5e.summonedCopy": true,
        "_stats.compendiumSource": actor.uuid
      }, {save: true});
    }
  }

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

  /**
   * Request a specific actor to summon from the player.
   * @param {SummonsProfile} profile  Profile used for summoning.
   * @returns {Promise<string|null>}  UUID of the concrete actor to summon or `null` if canceled.
   */
  async queryActor(profile) {
    const locked = {
      documentClass: "Actor",
      types: new Set(["npc"]),
      additional: {
        cr: { max: simplifyBonus(profile.cr, this.getRollData({ deterministic: true })) }
      }
    };
    if ( profile.types.size ) locked.additional.type = Array.from(profile.types).reduce((obj, type) => {
      obj[type] = 1;
      return obj;
    }, {});
    return CompendiumBrowser.selectOne({ filters: { locked } });
  }

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

  /**
   * Prepare the updates to apply to the summoned actor and its token.
   * @param {Actor5e} actor                   Actor that will be modified.
   * @param {SummonsProfile} profile          Summoning profile used to summon the actor.
   * @param {SummoningConfiguration} options  Configuration data for summoning behavior.
   * @returns {Promise<{actorChanges: object, tokenChanges: object}>}  Changes that will be applied to the actor,
   *                                                                   its items, and its token.
   */
  async getChanges(actor, profile, options) {
    const actorUpdates = { effects: [], items: [] };
    const tokenUpdates = {};
    const rollData = { ...this.getRollData(), summon: actor.getRollData() };
    const prof = rollData.attributes?.prof ?? 0;

    // Add flags
    actorUpdates["flags.dnd5e.summon"] = {
      level: this.relevantLevel,
      mod: rollData.mod,
      origin: this.item.uuid,
      activity: this.id,
      profile: profile._id
    };

    // Match proficiency
    if ( this.match.proficiency ) {
      const proficiencyEffect = new ActiveEffect({
        _id: staticID("dnd5eMatchProficiency"),
        changes: [{
          key: "system.attributes.prof",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: prof
        }],
        disabled: false,
        icon: "icons/skills/targeting/crosshair-bars-yellow.webp",
        name: game.i18n.localize("DND5E.SUMMON.FIELDS.match.proficiency.label")
      });
      actorUpdates.effects.push(proficiencyEffect.toObject());
    }

    // Add bonus to AC
    if ( this.bonuses.ac ) {
      const acBonus = new Roll(this.bonuses.ac, rollData);
      await acBonus.evaluate();
      if ( acBonus.total ) {
        if ( actor.system.attributes.ac.calc === "flat" ) {
          actorUpdates["system.attributes.ac.flat"] = (actor.system.attributes.ac.flat ?? 0) + acBonus.total;
        } else {
          actorUpdates.effects.push((new ActiveEffect({
            _id: staticID("dnd5eACBonus"),
            changes: [{
              key: "system.attributes.ac.bonus",
              mode: CONST.ACTIVE_EFFECT_MODES.ADD,
              value: acBonus.total
            }],
            disabled: false,
            icon: "icons/magic/defensive/shield-barrier-blue.webp",
            name: game.i18n.localize("DND5E.SUMMON.FIELDS.bonuses.ac.label")
          })).toObject());
        }
      }
    }

    // Add bonus to HD
    if ( this.bonuses.hd && (actor.type === "npc") ) {
      const hdBonus = new Roll(this.bonuses.hd, rollData);
      await hdBonus.evaluate();
      if ( hdBonus.total ) {
        actorUpdates.effects.push((new ActiveEffect({
          _id: staticID("dnd5eHDBonus"),
          changes: [{
            key: "system.attributes.hd.max",
            mode: CONST.ACTIVE_EFFECT_MODES.ADD,
            value: hdBonus.total
          }],
          disabled: false,
          icon: "icons/sundries/gaming/dice-runed-brown.webp",
          name: game.i18n.localize("DND5E.SUMMON.FIELDS.bonuses.hd.label")
        })).toObject());
      }
    }

    // Add bonus to HP
    if ( this.bonuses.hp ) {
      const hpBonus = new Roll(this.bonuses.hp, rollData);
      await hpBonus.evaluate();

      // If non-zero hp bonus, apply as needed for this actor.
      // Note: Only unlinked actors will have their current HP set to their new max HP
      if ( hpBonus.total ) {

        // Helper function for modifying max HP ('bonuses.overall' or 'max')
        const maxHpEffect = hpField => {
          return (new ActiveEffect({
            _id: staticID("dnd5eHPBonus"),
            changes: [{
              key: `system.attributes.hp.${hpField}`,
              mode: CONST.ACTIVE_EFFECT_MODES.ADD,
              value: hpBonus.total
            }],
            disabled: false,
            icon: "icons/magic/life/heart-glowing-red.webp",
            name: game.i18n.localize("DND5E.SUMMON.FIELDS.bonuses.hp.label")
          })).toObject();
        };

        if ( !foundry.utils.isEmpty(actor.classes) && !actor._source.system.attributes.hp.max ) {
          // Actor has classes without a hard-coded max -- apply bonuses to 'overall'
          actorUpdates.effects.push(maxHpEffect("bonuses.overall"));
        } else if ( actor.prototypeToken.actorLink ) {
          // Otherwise, linked actors boost HP via 'max' AE
          actorUpdates.effects.push(maxHpEffect("max"));
        } else {
          // Unlinked actors assumed to always be "fresh" copies with bonus HP added to both
          // Max HP and Current HP
          actorUpdates["system.attributes.hp.max"] = actor.system.attributes.hp.max + hpBonus.total;
          actorUpdates["system.attributes.hp.value"] = actor.system.attributes.hp.value + hpBonus.total;
        }
      }
    }

    // Change creature size
    if ( this.creatureSizes.size ) {
      const size = this.creatureSizes.has(options.creatureSize) ? options.creatureSize : this.creatureSizes.first();
      const config = CONFIG.DND5E.actorSizes[size];
      if ( config ) {
        actorUpdates["system.traits.size"] = size;
        tokenUpdates.width = config.token ?? 1;
        tokenUpdates.height = config.token ?? 1;
      }
    }

    // Change creature type
    if ( this.creatureTypes.size ) {
      const type = this.creatureTypes.has(options.creatureType) ? options.creatureType : this.creatureTypes.first();
      if ( actor.system.details?.race instanceof Item ) {
        actorUpdates.items.push({ _id: actor.system.details.race.id, "system.type.value": type });
      } else {
        actorUpdates["system.details.type.value"] = type;
      }
    }

    const attackDamageBonus = Roll.replaceFormulaData(this.bonuses.attackDamage ?? "", rollData);
    const saveDamageBonus = Roll.replaceFormulaData(this.bonuses.saveDamage ?? "", rollData);
    const healingBonus = Roll.replaceFormulaData(this.bonuses.healing ?? "", rollData);
    for ( const item of actor.items ) {
      if ( !item.system.activities?.size ) continue;
      const changes = [];

      // Match attacks
      if ( this.match.attacks && item.system.hasAttack ) {
        const ability = this.ability ?? this.item.abilityMod ?? rollData.attributes?.spellcasting;
        const actionType = item.system.activities.getByType("attack")[0].actionType;
        const typeMapping = { mwak: "msak", rwak: "rsak" };
        const parts = [
          rollData.abilities?.[ability]?.mod,
          prof,
          rollData.bonuses?.[typeMapping[actionType] ?? actionType]?.attack
        ].filter(p => p);
        changes.push({
          key: "system.attack.bonus",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: parts.join(" + ")
        }, {
          key: "system.attack.flat",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: true
        });
      }

      // Match saves
      if ( this.match.saves && item.hasSave ) {
        let dc = rollData.abilities?.[this.ability]?.dc ?? rollData.attributes.spell.dc;
        if ( this.item.type === "spell" ) {
          const ability = this.item.system.availableAbilities?.first();
          if ( ability ) dc = rollData.abilities[ability]?.dc ?? dc;
        }
        changes.push({
          key: "system.save.dc",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: dc
        }, {
          key: "system.save.scaling",
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: "flat"
        });
      }

      // Damage bonus
      let damageBonus;
      if ( item.hasAttack ) damageBonus = attackDamageBonus;
      else if ( item.hasSave ) damageBonus = saveDamageBonus;
      else if ( item.isHealing ) damageBonus = healingBonus;
      if ( damageBonus && item.system.activities.find(a => a.damage?.parts?.length || a.healing?.formula) ) {
        changes.push({
          key: "system.damage.bonus",
          mode: CONST.ACTIVE_EFFECT_MODES.ADD,
          value: damageBonus
        });
      }

      if ( changes.length ) {
        const effect = (new ActiveEffect({
          _id: staticID("dnd5eItemChanges"),
          changes,
          disabled: false,
          icon: "icons/skills/melee/strike-slashes-orange.webp",
          name: game.i18n.localize("DND5E.SUMMON.ItemChanges.Label"),
          origin: this.uuid,
          type: "enchantment"
        })).toObject();
        actorUpdates.items.push({ _id: item.id, effects: [effect] });
      }
    }

    // Add applied effects
    actorUpdates.effects.push(...this.effects.map(e => e.effect?.toObject()).filter(e => e));

    return { actorUpdates, tokenUpdates };
  }

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

  /**
   * Determine where the summons should be placed on the scene.
   * @param {PrototypeToken} token            Token to be placed.
   * @param {SummonsProfile} profile          Profile used for summoning.
   * @param {SummoningConfiguration} options  Additional summoning options.
   * @returns {Promise<PlacementData[]>}
   */
  async getPlacement(token, profile, options) {
    // Ensure the token matches the final size
    if ( this.creatureSizes.size ) {
      const size = this.creatureSizes.has(options.creatureSize) ? options.creatureSize : this.creatureSizes.first();
      const config = CONFIG.DND5E.actorSizes[size];
      if ( config ) token = token.clone({ width: config.token ?? 1, height: config.token ?? 1 });
    }

    const rollData = this.getRollData();
    const count = new Roll(profile.count || "1", rollData);
    await count.evaluate();
    return TokenPlacement.place({ tokens: Array(parseInt(count.total)).fill(token) });
  }

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

  /**
   * Configuration for creating a modified token.
   *
   * @typedef {object} TokenUpdateData
   * @property {Actor5e} actor            Original actor from which the token will be created.
   * @property {PlacementData} placement  Information on the location to summon the token.
   * @property {object} tokenUpdates      Additional updates that will be applied to token data.
   * @property {object} actorUpdates      Updates that will be applied to actor delta.
   */

  /**
   * Create token data ready to be summoned.
   * @param {config} TokenUpdateData  Configuration for creating a modified token.
   * @returns {object}
   */
  async getTokenData({ actor, placement, tokenUpdates, actorUpdates }) {
    if ( actor.prototypeToken.randomImg && !game.user.can("FILES_BROWSE") ) {
      tokenUpdates.texture ??= {};
      tokenUpdates.texture.src ??= actor.img;
      ui.notifications.warn("DND5E.SUMMON.Warning.Wildcard", { localize: true });
    }

    delete placement.prototypeToken;
    const tokenDocument = await actor.getTokenDocument(foundry.utils.mergeObject(placement, tokenUpdates));

    // Linked summons require more explicit updates before token creation.
    // Unlinked summons can take actor delta directly.
    if ( tokenDocument.actorLink ) {
      const { effects, items, ...rest } = actorUpdates;
      await tokenDocument.actor.update(rest);
      await tokenDocument.actor.updateEmbeddedDocuments("Item", items);

      const { newEffects, oldEffects } = effects.reduce((acc, curr) => {
        const target = tokenDocument.actor.effects.get(curr._id) ? "oldEffects" : "newEffects";
        acc[target].push(curr);
        return acc;
      }, { newEffects: [], oldEffects: [] });

      await tokenDocument.actor.updateEmbeddedDocuments("ActiveEffect", oldEffects);
      await tokenDocument.actor.createEmbeddedDocuments("ActiveEffect", newEffects, {keepId: true});
    } else {
      tokenDocument.delta.updateSource(actorUpdates);
      if ( actor.prototypeToken.appendNumber ) TokenPlacement.adjustAppendedNumber(tokenDocument, placement);
    }

    return tokenDocument.toObject();
  }

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

  /**
   * Handle placing a summons from the chat card.
   * @this {SummonActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static async #placeSummons(event, target, message) {
    const config = {
      create: { summons: true },
      summons: {}
    };
    let needsConfiguration = false;

    // No profile specified and only one profile on item, use that one
    const profiles = this.availableProfiles;
    if ( profiles.length === 1 ) config.summons.profile = profiles[0]._id;
    else needsConfiguration = true;

    // More than one creature size or type requires configuration
    if ( (this.creatureSizes.size > 1) || (this.creatureTypes.size > 1) ) needsConfiguration = true;

    if ( needsConfiguration ) {
      try {
        await SummonUsageDialog.create(this, config, {
          button: {
            icon: "fa-solid fa-spaghetti-monster-flying",
            label: "DND5E.SUMMON.Action.Summon"
          },
          display: {
            all: false,
            create: { summons: true }
          }
        });
      } catch(err) {
        return;
      }
    }

    try {
      await this.placeSummons(config.summons);
    } catch(err) {
      Hooks.onError("SummonsActivity#placeSummons", err, { log: "error", notify: "error" });
    }
  }
}

/**
 * Sheet for the utility activity.
 */
class UtilitySheet extends ActivitySheet {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["utility-activity"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    identity: {
      template: "systems/dnd5e/templates/activity/utility-identity.hbs",
      templates: super.PARTS.identity.templates
    },
    effect: {
      template: "systems/dnd5e/templates/activity/utility-effect.hbs",
      templates: super.PARTS.effect.templates
    }
  };
}

const { BooleanField: BooleanField$w, SchemaField: SchemaField$E, StringField: StringField$U } = foundry.data.fields;

/**
 * Data model for an utility activity.
 *
 * @property {object} roll
 * @property {string} roll.formula   Arbitrary formula that can be rolled.
 * @property {string} roll.name      Label for the rolling button.
 * @property {boolean} roll.prompt   Should the roll configuration dialog be displayed?
 * @property {boolean} roll.visible  Should the rolling button be visible to all players?
 */
class UtilityActivityData extends BaseActivityData {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ...super.defineSchema(),
      roll: new SchemaField$E({
        formula: new FormulaField(),
        name: new StringField$U(),
        prompt: new BooleanField$w(),
        visible: new BooleanField$w()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @override */
  static transformTypeData(source, activityData, options) {
    return foundry.utils.mergeObject(activityData, {
      roll: {
        formula: source.system.formula ?? "",
        name: "",
        prompt: false,
        visible: false
      }
    });
  }
}

/**
 * Generic activity for applying effects and rolling an arbitrary die.
 */
class UtilityActivity extends ActivityMixin(UtilityActivityData) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.UTILITY"];

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

  /** @inheritDoc */
  static metadata = Object.freeze(
    foundry.utils.mergeObject(super.metadata, {
      type: "utility",
      img: "systems/dnd5e/icons/svg/activity/utility.svg",
      title: "DND5E.UTILITY.Title",
      sheetClass: UtilitySheet,
      usage: {
        actions: {
          rollFormula: UtilityActivity.#rollFormula
        }
      }
    }, { inplace: false })
  );

  /* -------------------------------------------- */
  /*  Activation                                  */
  /* -------------------------------------------- */

  /** @override */
  _usageChatButtons(message) {
    if ( !this.roll.formula ) return super._usageChatButtons(message);
    return [{
      label: this.roll.name || game.i18n.localize("DND5E.Roll"),
      icon: '<i class="fa-solid fa-dice" inert></i>',
      dataset: {
        action: "rollFormula",
        visibility: this.roll.visible ? "all" : undefined
      }
    }].concat(super._usageChatButtons(message));
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /**
   * Roll the formula attached to this utility.
   * @param {BasicRollProcessConfiguration} [config]   Configuration information for the roll.
   * @param {BasicRollDialogConfiguration} [dialog]    Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} [message]  Configuration for the roll message.
   * @returns {Promise<BasicRoll[]|void>}              The created Roll instances.
   */
  async rollFormula(config={}, dialog={}, message={}) {
    if ( !this.roll.formula ) {
      console.warn(`No formula defined for the activity ${this.name} on ${this.item.name} (${this.uuid}).`);
      return;
    }

    const rollConfig = foundry.utils.deepClone(config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "formula"];
    rollConfig.rolls = [{ parts: [this.roll.formula], data: this.getRollData() }].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      configure: this.roll.prompt,
      options: {
        window: {
          title: this.item.name,
          subtitle: "DND5E.RollConfiguration.Title",
          icon: this.item.img
        }
      }
    }, dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flavor: `${this.item.name} - ${this.roll.label || game.i18n.localize("DND5E.OtherFormula")}`,
        flags: {
          dnd5e: {
            ...this.messageFlags,
            messageType: "roll",
            roll: { type: "generic" }
          }
        }
      }
    }, message);

    if ( "dnd5e.preRollFormula" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preRollFormula` hook has been deprecated and replaced with `dnd5e.preRollFormulaV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      const hookData = {
        formula: rollConfig.rolls[0].parts[0], data: rollConfig.rolls[0].data, chatMessage: messageConfig.create
      };
      if ( Hooks.call("dnd5e.preRollFormula", this.item, hookData) === false ) return;
      rollConfig.rolls[0].parts[0] = hookData.formula;
      rollConfig.rolls[0].data = hookData.data;
      messageConfig.create = hookData.chatMessage;
    }

    const rolls = await CONFIG.Dice.BasicRoll.build(rollConfig, dialogConfig, messageConfig);
    if ( !rolls.length ) return;

    /**
     * A hook event that fires after a formula has been rolled for a Utility activity.
     * @function dnd5e.rollFormulaV2
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls             The resulting rolls.
     * @param {object} data
     * @param {UtilityActivity} data.subject  The Activity that performed the roll.
     */
    Hooks.callAll("dnd5e.rollFormulaV2", rolls, { subject: this });

    if ( "dnd5e.rollFormula" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.rollFormula` hook has been deprecated and replaced with `dnd5e.rollFormulaV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      Hooks.callAll("dnd5e.rollFormula", this.item, rolls[0]);
    }

    return rolls;
  }

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

  /**
   * Handle rolling the formula attached to this utility.
   * @this {UtilityActivity}
   * @param {PointerEvent} event     Triggering click event.
   * @param {HTMLElement} target     The capturing HTML element which defined a [data-action].
   * @param {ChatMessage5e} message  Message associated with the activation.
   */
  static #rollFormula(event, target, message) {
    this.rollFormula({ event });
  }
}

var _module$s = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivityMixin: ActivityMixin,
  AttackActivity: AttackActivity,
  CastActivity: CastActivity,
  CheckActivity: CheckActivity,
  DamageActivity: DamageActivity,
  EnchantActivity: EnchantActivity,
  EnchantmentError: EnchantmentError$1,
  ForwardActivity: ForwardActivity,
  HealActivity: HealActivity,
  OrderActivity: OrderActivity,
  SaveActivity: SaveActivity,
  SummonActivity: SummonActivity,
  UtilityActivity: UtilityActivity
});

/**
 * Base configuration application for advancements that can be extended by other types to implement custom
 * editing interfaces.
 */
let AdvancementConfig$1 = class AdvancementConfig extends PseudoDocumentSheet {
  constructor(advancement={}, options={}) {
    if ( advancement instanceof dnd5e.documents.advancement.Advancement ) {
      options.document = advancement;
      // TODO: Add deprecation warning for this calling pattern once system has switched over to using the sheet
      // getter on Advancement, rather than creating separately
    } else options = { ...advancement, ...options };
    super(options);
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["advancement", "grid-columns"],
    window: {
      icon: "fa-solid fa-person-rays"
    },
    actions: {
      deleteItem: AdvancementConfig.#deleteDroppedItem
    },
    dropKeyPath: null,
    position: {
      width: 400
    }
  };

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

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    }
  };

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

  /**
   * The advancement being created or edited.
   * @type {Advancement}
   */
  get advancement() {
    return this.document;
  }

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

  /** @inheritDoc */
  get title() {
    return this.advancement.constructor.metadata.title;
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const levels = Array.fromRange(CONFIG.DND5E.maxLevel + 1).map(l => ({ value: l, label: l }));
    if ( ["class", "subclass"].includes(this.item.type) ) delete levels[0];
    else levels[0].label = game.i18n.localize("DND5E.ADVANCEMENT.Config.AnyLevel");
    const context = {
      ...(await super._prepareContext(options)),
      advancement: this.advancement,
      configuration: {
        data: this.advancement.configuration,
        fields: this.advancement.configuration?.schema?.fields
      },
      fields: this.advancement.schema.fields,
      source: this.advancement._source,
      default: {
        title: this.advancement.constructor.metadata.title,
        icon: this.advancement.constructor.metadata.icon,
        hint: ""
      },
      levels,
      classRestrictionOptions: [
        { value: "", label: game.i18n.localize("DND5E.AdvancementClassRestrictionNone") },
        { value: "primary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionPrimary") },
        { value: "secondary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionSecondary") }
      ],
      showClassRestrictions: this.item.type === "class",
      showLevelSelector: !this.advancement.constructor.metadata.multiLevel
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop({
      dragSelector: ".draggable",
      dropSelector: null,
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

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

  /**
   * Handle deleting an existing Item entry from the Advancement.
   * @this {AdvancementConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #deleteDroppedItem(event, target) {
    const uuidToDelete = target.closest("[data-item-uuid]")?.dataset.itemUuid;
    if ( !uuidToDelete ) return;
    const items = foundry.utils.getProperty(this.advancement.configuration, this.options.dropKeyPath);
    const updates = { configuration: await this.prepareConfigurationUpdate({
      [this.options.dropKeyPath]: items.filter(i => i.uuid !== uuidToDelete)
    }) };
    await this.advancement.update(updates);
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /**
   * Perform any changes to configuration data before it is saved to the advancement.
   * @param {object} configuration  Configuration object.
   * @returns {object}              Modified configuration.
   */
  async prepareConfigurationUpdate(configuration) {
    return configuration;
  }

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

  /** @inheritDoc */
  async _processSubmitData(event, submitData) {
    submitData.configuration ??= {};
    submitData.configuration = await this.prepareConfigurationUpdate(submitData.configuration);
    await this.advancement.update(submitData);
  }

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

  /**
   * Helper method to take an object and apply updates that remove any empty keys.
   * @param {object} object  Object to be cleaned.
   * @returns {object}       Copy of object with only non false-ish values included and others marked
   *                         using `-=` syntax to be removed by update process.
   * @protected
   */
  static _cleanedObject(object) {
    return Object.entries(object).reduce((obj, [key, value]) => {
      let keep = false;
      if ( foundry.utils.getType(value) === "Object" ) keep = Object.values(value).some(v => v);
      else if ( value ) keep = true;
      if ( keep ) obj[key] = value;
      else obj[`-=${key}`] = null;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle beginning drag events on the sheet.
   * @param {DragEvent} event  The initiating drag start event.
   * @protected
   */
  async _onDragStart(event) {}

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

  /**
   * Handle dropping items onto the sheet.
   * @param {DragEvent} event  The concluding drag event.
   * @protected
   */
  async _onDrop(event) {
    if ( !this.options.dropKeyPath ) return;

    // Try to extract the data
    const data = TextEditor.getDragEventData(event);

    if ( data?.type !== "Item" ) return;
    const item = await Item.implementation.fromDropData(data);

    try {
      this._validateDroppedItem(event, item);
    } catch(err) {
      ui.notifications.error(err.message);
      return;
    }

    const existingItems = foundry.utils.getProperty(this.advancement.configuration, this.options.dropKeyPath);

    // Abort if this uuid is the parent item
    if ( item.uuid === this.item.uuid ) {
      ui.notifications.error("DND5E.ADVANCEMENT.ItemGrant.Warning.Recursive", {localize: true});
      return;
    }

    // Abort if this uuid exists already
    if ( existingItems.find(i => i.uuid === item.uuid) ) {
      ui.notifications.warn("DND5E.ADVANCEMENT.ItemGrant.Warning.Duplicate", {localize: true});
      return;
    }

    await this.advancement.update({[`configuration.${this.options.dropKeyPath}`]: [
      ...existingItems, { uuid: item.uuid }
    ]});
  }

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

  /**
   * Called when an item is dropped to validate the Item before it is saved. An error should be thrown
   * if the item is invalid.
   * @param {Event} event  Triggering drop event.
   * @param {Item5e} item  The materialized Item that was dropped.
   * @throws An error if the item is invalid.
   * @protected
   */
  _validateDroppedItem(event, item) {}
};

/**
 * Base class for the advancement interface displayed by the advancement prompt that should be subclassed by
 * individual advancement types.
 *
 * @param {Item5e} item           Item to which the advancement belongs.
 * @param {string} advancementId  ID of the advancement this flow modifies.
 * @param {number} level          Level for which to configure this flow.
 * @param {object} [options={}]   Application rendering options.
 */
class AdvancementFlow extends FormApplication {
  constructor(item, advancementId, level, options={}) {
    super({}, options);

    /**
     * The item that houses the Advancement.
     * @type {Item5e}
     */
    this.item = item;

    /**
     * ID of the advancement this flow modifies.
     * @type {string}
     * @private
     */
    this._advancementId = advancementId;

    /**
     * Level for which to configure this flow.
     * @type {number}
     */
    this.level = level;

    /**
     * Data retained by the advancement manager during a reverse step. If restoring data using Advancement#restore,
     * this data should be used when displaying the flow's form.
     * @type {object|null}
     */
    this.retainedData = null;
  }

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

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/advancement-flow.hbs",
      popOut: false
    });
  }

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

  /** @inheritDoc */
  static _customElements = super._customElements.concat(["dnd5e-checkbox"]);

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

  /** @inheritDoc */
  get id() {
    return `actor-${this.advancement.item.id}-advancement-${this.advancement.id}-${this.level}`;
  }

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

  /** @inheritDoc */
  get title() {
    return this.advancement.title;
  }

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

  /**
   * The Advancement object this flow modifies.
   * @type {Advancement|null}
   */
  get advancement() {
    return this.item.advancement?.byId[this._advancementId] ?? null;
  }

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

  /**
   * Set the retained data for this flow. This method gives the flow a chance to do any additional prep
   * work required for the retained data before the application is rendered.
   * @param {object} data  Retained data associated with this flow.
   */
  async retainData(data) {
    this.retainedData = data;
  }

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

  /** @inheritDoc */
  getData() {
    return {
      appId: this.id,
      advancement: this.advancement,
      type: this.advancement.constructor.typeName,
      title: this.title,
      hint: this.advancement.hint,
      summary: this.advancement.summaryForLevel(this.level),
      level: this.level
    };
  }

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

  /** @inheritDoc */
  async _render(...args) {
    await super._render(...args);

    // Call setPosition on manager to adjust for size changes
    this.options.manager?.setPosition();
  }

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

  /**
   * Retrieve automatic application data from the advancement, if supported.
   * @returns {object|false}  Data to pass to the apply method, or `false` if advancement requirers user intervention.
   */
  getAutomaticApplicationValue() {
    return this.advancement.automaticApplicationValue(this.level);
  }

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

  /** @inheritDoc */
  async _updateObject(event, formData) {
    await this.advancement.apply(this.level, formData);
  }

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

  /** @inheritDoc */
  _canDragDrop(selector) {
    return true;
  }

}

/**
 * Object describing the proficiency for a specific ability or skill.
 *
 * @param {number} proficiency   Actor's flat proficiency bonus based on their current level.
 * @param {number} multiplier    Value by which to multiply the actor's base proficiency value.
 * @param {boolean} [roundDown]  Should half-values be rounded up or down?
 */
class Proficiency {
  constructor(proficiency, multiplier, roundDown=true) {

    /**
     * Base proficiency value of the actor.
     * @type {number}
     * @private
     */
    this._baseProficiency = Number(proficiency ?? 0);

    /**
     * Value by which to multiply the actor's base proficiency value.
     * @type {number}
     */
    this.multiplier = Number(multiplier ?? 0);

    /**
     * Direction decimal results should be rounded ("up" or "down").
     * @type {string}
     */
    this.rounding = roundDown ? "down" : "up";
  }

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

  /**
   * Should only deterministic proficiency be returned, regardless of system settings?
   * @type {boolean}
   */
  deterministic = false;

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

  /**
   * Flat proficiency value regardless of proficiency mode.
   * @type {number}
   */
  get flat() {
    const roundMethod = (this.rounding === "down") ? Math.floor : Math.ceil;
    return roundMethod(this.multiplier * this._baseProficiency);
  }

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

  /**
   * Dice-based proficiency value regardless of proficiency mode.
   * @type {string}
   */
  get dice() {
    if ( (this._baseProficiency === 0) || (this.multiplier === 0) ) return "0";
    const roundTerm = (this.rounding === "down") ? "floor" : "ceil";
    if ( this.multiplier === 0.5 ) {
      return `${roundTerm}(1d${this._baseProficiency * 2} / 2)`;
    } else {
      return `${this.multiplier}d${this._baseProficiency * 2}`;
    }
  }

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

  /**
   * Either flat or dice proficiency term based on configured setting.
   * @type {string}
   */
  get term() {
    return (game.settings.get("dnd5e", "proficiencyModifier") === "dice") && !this.deterministic
      ? this.dice : String(this.flat);
  }

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

  /**
   * Whether the proficiency is greater than zero.
   * @type {boolean}
   */
  get hasProficiency() {
    return (this._baseProficiency > 0) && (this.multiplier > 0);
  }

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

  /**
   * Calculate an actor's proficiency modifier based on level or CR.
   * @param {number} level  Level or CR To use for calculating proficiency modifier.
   * @returns {number}      Proficiency modifier.
   */
  static calculateMod(level) {
    return Math.floor((level + 7) / 4);
  }

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

  /**
   * Return a clone of this proficiency with any changes applied.
   * @param {object} [updates={}]
   * @param {number} updates.proficiency  Actor's flat proficiency bonus based on their current level.
   * @param {number} updates.multiplier   Value by which to multiply the actor's base proficiency value.
   * @param {boolean} updates.roundDown   Should half-values be rounded up or down?
   * @returns {Proficiency}
   */
  clone({ proficiency, multiplier, roundDown }={}) {
    proficiency ??= this._baseProficiency;
    multiplier ??= this.multiplier;
    roundDown ??= this.rounding === "down";
    return new this.constructor(proficiency, multiplier, roundDown);
  }

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

  /**
   * Override the default `toString` method to return flat proficiency for backwards compatibility in formula.
   * @returns {string}  Either flat or dice proficiency term based on configured setting.
   */
  toString() {
    return this.term;
  }
}

/**
 * Data Model variant with some extra methods to support template mix-ins.
 *
 * **Note**: This uses some advanced Javascript techniques that are not necessary for most data models.
 * Please refer to the [advancement data models]{@link BaseAdvancement} for an example of a more typical usage.
 *
 * In template.json, each Actor or Item type can incorporate several templates which are chunks of data that are
 * common across all the types that use them. One way to represent them in the schema for a given Document type is to
 * duplicate schema definitions for the templates and write them directly into the Data Model for the Document type.
 * This works fine for small templates or systems that do not need many Document types but for more complex systems
 * this boilerplate can become prohibitive.
 *
 * Here we have opted to instead create a separate Data Model for each template available. These define their own
 * schemas which are then mixed-in to the final schema for the Document type's Data Model. A Document type Data Model
 * can define its own schema unique to it, and then add templates in direct correspondence to those in template.json
 * via SystemDataModel.mixin.
 */
class SystemDataModel extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static _enableV10Validation = true;

  /**
   * System type that this system data model represents (e.g. "character", "npc", "vehicle").
   * @type {string}
   */
  static _systemType;

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

  /**
   * Base templates used for construction.
   * @type {*[]}
   * @private
   */
  static _schemaTemplates = [];

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

  /**
   * The field names of the base templates used for construction.
   * @type {Set<string>}
   * @private
   */
  static get _schemaTemplateFields() {
    const fieldNames = Object.freeze(new Set(this._schemaTemplates.map(t => t.schema.keys()).flat()));
    Object.defineProperty(this, "_schemaTemplateFields", {
      value: fieldNames,
      writable: false,
      configurable: false
    });
    return fieldNames;
  }

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

  /**
   * A list of properties that should not be mixed-in to the final type.
   * @type {Set<string>}
   * @private
   */
  static _immiscible = new Set(["length", "mixed", "name", "prototype", "cleanData", "_cleanData",
    "_initializationOrder", "validateJoint", "_validateJoint", "migrateData", "_migrateData",
    "shimData", "_shimData", "defineSchema"]);

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

  /**
   * @typedef {object} SystemDataModelMetadata
   * @property {typeof DataModel} [systemFlagsModel]  Model that represents flags data within the dnd5e namespace.
   */

  /**
   * Metadata that describes this DataModel.
   * @type {SystemDataModelMetadata}
   */
  static metadata = Object.freeze({
    systemFlagsModel: null
  });

  get metadata() {
    return this.constructor.metadata;
  }

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

  /**
   * Filters available for this item type when using the compendium browser.
   * @returns {CompendiumBrowserFilterDefinition}
   */
  static get compendiumBrowserFilters() {
    return new Map();
  }

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

  /**
   * Key path to the description used for default embeds.
   * @type {string|null}
   */
  get embeddedDescriptionKeyPath() {
    return null;
  }

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

  /** @inheritDoc */
  static defineSchema() {
    const schema = {};
    for ( const template of this._schemaTemplates ) {
      if ( !template.defineSchema ) {
        throw new Error(`Invalid dnd5e template mixin ${template} defined on class ${this.constructor}`);
      }
      this.mergeSchema(schema, template.defineSchema());
    }
    return schema;
  }

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

  /**
   * Merge two schema definitions together as well as possible.
   * @param {DataSchema} a  First schema that forms the basis for the merge. *Will be mutated.*
   * @param {DataSchema} b  Second schema that will be merged in, overwriting any non-mergeable properties.
   * @returns {DataSchema}  Fully merged schema.
   */
  static mergeSchema(a, b) {
    Object.assign(a, b);
    return a;
  }

  /* -------------------------------------------- */
  /*  Data Cleaning                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static cleanData(source, options) {
    this._cleanData(source, options);
    return super.cleanData(source, options);
  }

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

  /**
   * Performs cleaning without calling DataModel.cleanData.
   * @param {object} [source]         The source data
   * @param {object} [options={}]     Additional options (see DataModel.cleanData)
   * @protected
   */
  static _cleanData(source, options) {
    for ( const template of this._schemaTemplates ) {
      template._cleanData(source, options);
    }
  }

  /* -------------------------------------------- */
  /*  Data Initialization                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static *_initializationOrder() {
    for ( const template of this._schemaTemplates ) {
      for ( const entry of template._initializationOrder() ) {
        entry[1] = this.schema.get(entry[0]);
        yield entry;
      }
    }
    for ( const entry of this.schema.entries() ) {
      if ( this._schemaTemplateFields.has(entry[0]) ) continue;
      yield entry;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Pre-creation logic for this system data.
   * @param {object} data               The initial data object provided to the document creation request.
   * @param {object} options            Additional options which modify the creation request.
   * @param {User} user                 The User requesting the document creation.
   * @returns {Promise<boolean|void>}   A return value of false indicates the creation operation should be cancelled.
   * @see {Document#_preCreate}
   * @protected
   */
  async _preCreate(data, options, user) {
    const actor = this.parent.actor;
    if ( (actor?.type !== "character") || !this.metadata?.singleton ) return;
    if ( actor.itemTypes[data.type]?.length ) {
      ui.notifications.error(game.i18n.format("DND5E.ActorWarningSingleton", {
        itemType: game.i18n.localize(CONFIG.Item.typeLabels[data.type]),
        actorType: game.i18n.localize(CONFIG.Actor.typeLabels[actor.type])
      }));
      return false;
    }
  }

  /* -------------------------------------------- */
  /*  Data Validation                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  validate(options={}) {
    if ( this.constructor._enableV10Validation === false ) return true;
    return super.validate(options);
  }

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

  /** @inheritDoc */
  static validateJoint(data) {
    this._validateJoint(data);
    return super.validateJoint(data);
  }

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

  /**
   * Performs joint validation without calling DataModel.validateJoint.
   * @param {object} data     The source data
   * @throws                  An error if a validation failure is detected
   * @protected
   */
  static _validateJoint(data) {
    for ( const template of this._schemaTemplates ) {
      template._validateJoint(data);
    }
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    this._migrateData(source);
    return super.migrateData(source);
  }

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

  /**
   * Performs migration without calling DataModel.migrateData.
   * @param {object} source     The source data
   * @protected
   */
  static _migrateData(source) {
    for ( const template of this._schemaTemplates ) {
      template._migrateData(source);
    }
  }

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

  /** @inheritDoc */
  static shimData(data, options) {
    this._shimData(data, options);
    return super.shimData(data, options);
  }

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

  /**
   * Performs shimming without calling DataModel.shimData.
   * @param {object} data         The source data
   * @param {object} [options]    Additional options (see DataModel.shimData)
   * @protected
   */
  static _shimData(data, options) {
    for ( const template of this._schemaTemplates ) {
      template._shimData(data, options);
    }
  }

  /* -------------------------------------------- */
  /*  Mixins                                      */
  /* -------------------------------------------- */

  /**
   * Mix multiple templates with the base type.
   * @param {...*} templates            Template classes to mix.
   * @returns {typeof SystemDataModel}  Final prepared type.
   */
  static mixin(...templates) {
    for ( const template of templates ) {
      if ( !(template.prototype instanceof SystemDataModel) ) {
        throw new Error(`${template.name} is not a subclass of SystemDataModel`);
      }
    }

    const Base = class extends this {};
    Object.defineProperty(Base, "_schemaTemplates", {
      value: Object.seal([...this._schemaTemplates, ...templates]),
      writable: false,
      configurable: false
    });

    for ( const template of templates ) {
      // Take all static methods and fields from template and mix in to base class
      for ( const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(template)) ) {
        if ( this._immiscible.has(key) ) continue;
        Object.defineProperty(Base, key, descriptor);
      }

      // Take all instance methods and fields from template and mix in to base class
      for ( const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(template.prototype)) ) {
        if ( ["constructor"].includes(key) ) continue;
        Object.defineProperty(Base.prototype, key, descriptor);
      }
    }

    return Base;
  }

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

  /** @override */
  async toEmbed(config, options={}) {
    const keyPath = this.embeddedDescriptionKeyPath;
    if ( !keyPath || !foundry.utils.hasProperty(this, keyPath) ) return null;
    const enriched = await TextEditor.enrichHTML(foundry.utils.getProperty(this, keyPath), {
      ...options,
      relativeTo: this.parent
    });
    const container = document.createElement("div");
    container.innerHTML = enriched;
    return container.children;
  }
}

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

/**
 * Variant of the SystemDataModel with some extra actor-specific handling.
 */
class ActorDataModel extends SystemDataModel {

  /**
   * @typedef {SystemDataModelMetadata} ActorDataModelMetadata
   * @property {boolean} supportsAdvancement  Can advancement be performed for this actor type?
   */

  /** @type {ActorDataModelMetadata} */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    supportsAdvancement: false
  }, {inplace: false}));

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

  /** @override */
  get embeddedDescriptionKeyPath() {
    return "details.biography.value";
  }

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

  /**
   * Other actors that are available for currency transfers from this actor.
   * @type {Actor5e[]}
   */
  get transferDestinations() {
    const primaryParty = game.settings.get("dnd5e", "primaryParty")?.actor;
    if ( !primaryParty?.system.members.ids.has(this.parent.id) ) return [];
    const destinations = primaryParty.system.members.map(m => m.actor).filter(a => a.isOwner && a !== this.parent);
    if ( primaryParty.isOwner ) destinations.unshift(primaryParty);
    return destinations;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Data preparation steps to perform after item data has been prepared, but before active effects are applied.
   */
  prepareEmbeddedData() {
    this._prepareScaleValues();
  }

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

  /**
   * Derive any values that have been scaled by the Advancement system.
   * Mutates the value of the `system.scale` object.
   * @protected
   */
  _prepareScaleValues() {
    this.scale = this.parent.items.reduce((scale, item) => {
      if ( CONFIG.DND5E.advancementTypes.ScaleValue.validItemTypes.has(item.type) ) {
        scale[item.identifier] = item.scaleValues;
      }
      return scale;
    }, {});
  }

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

  /**
   * Prepare a data object which defines the data schema used by dice roll commands against this Actor.
   * @param {object} [options]
   * @param {boolean} [options.deterministic] Whether to force deterministic values for data properties that could be
   *                                          either a die term or a flat term.
   * @returns {object}
   */
  getRollData({ deterministic=false }={}) {
    const data = { ...this };
    data.prof = new Proficiency(this.attributes?.prof ?? 0, 1);
    data.prof.deterministic = deterministic;
    return data;
  }

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

  /**
   * Reset combat-related uses.
   * @param {string[]} periods               Which recovery periods should be considered.
   * @param {CombatRecoveryResults} results  Updates to perform on the actor and containing items.
   */
  async recoverCombatUses(periods, results) {}
}

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

/**
 * Variant of the SystemDataModel with support for rich item tooltips.
 */
class ItemDataModel extends SystemDataModel {

  /**
   * @typedef {SystemDataModelMetadata} ItemDataModelMetadata
   * @property {boolean} enchantable    Can this item be modified by enchantment effects?
   * @property {boolean} inventoryItem  Should this item be listed with an actor's inventory?
   * @property {number} inventoryOrder  Order this item appears in the actor's inventory, smaller numbers are earlier.
   * @property {boolean} singleton      Should only a single item of this type be allowed on an actor?
   */

  /** @type {ItemDataModelMetadata} */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: false,
    inventoryItem: false,
    inventoryOrder: Infinity,
    singleton: false
  }, {inplace: false}));

  /**
   * The handlebars template for rendering item tooltips.
   * @type {string}
   */
  static ITEM_TOOLTIP_TEMPLATE = "systems/dnd5e/templates/items/parts/item-tooltip.hbs";

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

  /**
   * Modes that can be used when making an attack with this item.
   * @type {FormSelectOption[]}
   */
  get attackModes() {
    return [];
  }

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

  /**
   * Set of abilities that can automatically be associated with this item.
   * @type {Set<string>|null}
   */
  get availableAbilities() {
    return null;
  }

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

  /** @override */
  get embeddedDescriptionKeyPath() {
    return game.user.isGM || (this.identified !== false) ? "description.value" : "unidentified.description";
  }

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

  /**
   * Scaling increase for this item type.
   * @type {number|null}
   */
  get scalingIncrease() {
    return null;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    if ( this.parent.isEmbedded && this.parent.actor?.items.has(this.parent.id) ) {
      const sourceId = this.parent.flags.dnd5e?.sourceId ?? this.parent._stats.compendiumSource
        ?? this.parent.flags.core?.sourceId;
      if ( sourceId ) this.parent.actor.sourcedItems?.set(sourceId, this.parent);
    }
  }

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

  /**
   * Render a rich tooltip for this item.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {{content: string, classes: string[]}}
   */
  async richTooltip(enrichmentOptions={}) {
    return {
      content: await renderTemplate(
        this.constructor.ITEM_TOOLTIP_TEMPLATE, await this.getCardData(enrichmentOptions)
      ),
      classes: ["dnd5e2", "dnd5e-tooltip", "item-tooltip"]
    };
  }

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

  /**
   * Prepare item card template data.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @param {Activity} [enrichmentOptions.activity]     Specific activity on item to use for customizing the data.
   * @returns {Promise<object>}
   */
  async getCardData({ activity, ...enrichmentOptions }={}) {
    const { name, type, img } = this.parent;
    let {
      price, weight, uses, identified, unidentified, description, school, materials
    } = this;
    const rollData = (activity ?? this.parent).getRollData();
    const isIdentified = identified !== false;
    const chat = isIdentified ? description.chat || description.value : unidentified?.description;
    description = game.user.isGM || isIdentified ? description.value : unidentified?.description;
    uses = this.hasLimitedUses && (game.user.isGM || identified) ? uses : null;
    price = game.user.isGM || identified ? price : null;

    const subtitle = [this.type?.label ?? game.i18n.localize(CONFIG.Item.typeLabels[this.parent.type])];
    const context = {
      name, type, img, price, weight, uses, school, materials,
      config: CONFIG.DND5E,
      controlHints: game.settings.get("dnd5e", "controlHints"),
      labels: foundry.utils.deepClone((activity ?? this.parent).labels),
      tags: this.parent.labels?.components?.tags,
      subtitle: subtitle.filterJoin(" &bull; "),
      description: {
        value: await TextEditor.enrichHTML(description ?? "", {
          rollData, relativeTo: this.parent, ...enrichmentOptions
        }),
        chat: await TextEditor.enrichHTML(chat ?? "", {
          rollData, relativeTo: this.parent, ...enrichmentOptions
        }),
        concealed: game.user.isGM && game.settings.get("dnd5e", "concealItemDescriptions") && !description.chat
      }
    };

    context.properties = [];

    if ( game.user.isGM || isIdentified ) {
      context.properties.push(
        ...this.cardProperties ?? [],
        ...Object.values((activity ? activity?.activationLabels : this.parent.labels.activations?.[0]) ?? {}),
        ...this.equippableItemCardProperties ?? []
      );
    }

    context.properties = context.properties.filter(_ => _);
    context.hasProperties = context.tags?.length || context.properties.length;
    return context;
  }

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

  /**
   * Determine the cost to craft this Item.
   * @param {object} [options]
   * @param {"buy"|"craft"|"none"} [options.baseItem="craft"]  Ignore base item if "none". Include full base item gold
   *                                                           price if "buy". Include base item craft costs if "craft".
   * @returns {Promise<{ days: number, gold: number }>}
   */
  async getCraftCost({ baseItem="craft" }={}) {
    let days = 0;
    let gold = 0;
    if ( !("price" in this) ) return { days, gold };
    const { price, type, rarity } = this;

    // Mundane Items
    if ( !this.properties.has("mgc") || !rarity ) {
      const { mundane } = CONFIG.DND5E.crafting;
      const valueInGP = price.valueInGP ?? 0;
      return { days: Math.ceil(valueInGP * mundane.days), gold: Math.floor(valueInGP * mundane.gold) };
    }

    const base = await getBaseItem(type.identifier ?? "", { fullItem: true });
    if ( base && (baseItem !== "none") ) {
      if ( baseItem === "buy" ) gold += base.system.price.valueInGP ?? 0;
      else {
        const costs = await base.system.getCraftCost();
        days += costs.days;
        gold += costs.gold;
      }
    }

    const { magic } = CONFIG.DND5E.crafting;
    if ( !(rarity in magic) ) return { days, gold };
    const costs = magic[rarity];
    return { days: days + costs.days, gold: gold + costs.gold };
  }

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

  /**
   * @typedef {object} FavoriteData5e
   * @property {string} img                  The icon path.
   * @property {string} title                The title.
   * @property {string|string[]} [subtitle]  An optional subtitle or several subtitle parts.
   * @property {number} [value]              A single value to display.
   * @property {number} [quantity]           The item's quantity.
   * @property {string|number} [modifier]    A modifier associated with the item.
   * @property {number} [passive]            A passive score associated with the item.
   * @property {object} [range]              The item's range.
   * @property {number} [range.value]        The first range increment.
   * @property {number|null} [range.long]    The second range increment.
   * @property {string} [range.units]        The range units.
   * @property {object} [save]               The item's saving throw.
   * @property {string} [save.ability]       The saving throw ability.
   * @property {number} [save.dc]            The saving throw DC.
   * @property {object} [uses]               Data on an item's uses.
   * @property {number} [uses.value]         The current available uses.
   * @property {number} [uses.max]           The maximum available uses.
   * @property {string} [uses.name]          The property to update on the item. If none is provided, the property will
   *                                         not be updatable.
   * @property {boolean} [toggle]            The effect's toggle state.
   * @property {boolean} [suppressed]        Whether the favorite is suppressed.
   */

  /**
   * Prepare item favorite data.
   * @returns {Promise<FavoriteData5e>}
   */
  async getFavoriteData() {
    return {
      img: this.parent.img,
      title: this.parent.name,
      subtitle: game.i18n.localize(CONFIG.Item.typeLabels[this.parent.type])
    };
  }

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

  /**
   * Prepare type-specific data for the Item sheet.
   * @param {object} context  Sheet context data.
   * @returns {Promise<void>}
   */
  async getSheetData(context) {}

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

  /**
   * Prepare a data object which defines the data schema used by dice roll commands against this Item.
   * @param {object} [options]
   * @param {boolean} [options.deterministic] Whether to force deterministic values for data properties that could be
   *                                          either a die term or a flat term.
   * @returns {object}
   */
  getRollData({ deterministic=false }={}) {
    const actorRollData = this.parent.actor?.getRollData({ deterministic }) ?? {};
    const data = { ...actorRollData, item: { ...this } };
    return data;
  }
}

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

/**
 * Data Model variant that does not export fields with an `undefined` value during `toObject(true)`.
 */
class SparseDataModel extends foundry.abstract.DataModel {
  /** @inheritDoc */
  toObject(source=true) {
    if ( !source ) return super.toObject(source);
    const clone = foundry.utils.flattenObject(this._source);
    // Remove any undefined keys from the source data
    Object.keys(clone).filter(k => clone[k] === undefined).forEach(k => delete clone[k]);
    return foundry.utils.expandObject(clone);
  }
}

/**
 * Data field that automatically selects the Advancement-specific configuration or value data models.
 *
 * @param {Advancement} advancementType  Advancement class to which this field belongs.
 */
class AdvancementDataField extends foundry.data.fields.ObjectField {
  constructor(advancementType, options={}) {
    super(options);
    this.advancementType = advancementType;
  }

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

  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {required: true});
  }

  /**
   * Get the DataModel definition for the specified field as defined in metadata.
   * @returns {typeof DataModel|null}  The DataModel class, or null.
   */
  getModel() {
    return this.advancementType.metadata?.dataModels?.[this.name];
  }

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

  /**
   * Get the defaults object for the specified field as defined in metadata.
   * @returns {object}
   */
  getDefaults() {
    return this.advancementType.metadata?.defaults?.[this.name] ?? {};
  }

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

  /** @inheritDoc */
  _cleanType(value, options) {
    if ( !(typeof value === "object") ) value = {};

    // Use a defined DataModel
    const cls = this.getModel();
    if ( cls ) return cls.cleanData(value, options);
    if ( options.partial ) return value;

    // Use the defined defaults
    const defaults = this.getDefaults();
    return foundry.utils.mergeObject(defaults, value, {inplace: false});
  }

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

  /** @inheritDoc */
  initialize(value, model, options={}) {
    const cls = this.getModel();
    if ( cls ) return new cls(value, {parent: model, ...options});
    return foundry.utils.deepClone(value);
  }

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

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    const cls = this.getModel();
    if ( cls ) cls.migrateDataSafe(fieldData);
  }
}

const { DocumentIdField: DocumentIdField$4, FilePathField, NumberField: NumberField$A, StringField: StringField$T } = foundry.data.fields;

/**
 * Base data model for advancement.
 *
 * @property {string} _id               The advancement's ID.
 * @property {string} type              Type of advancement.
 * @property {*} configuration          Type-specific configuration data.
 * @property {*} value                  Type-specific value data after the advancement is applied.
 * @property {number} level             For single-level advancement, the level at which it should apply.
 * @property {string} title             Optional custom title.
 * @property {string} hint              Brief description of what the advancement does or guidance for the player.
 * @property {string} icon              Optional custom icon.
 * @property {string} classRestriction  Should this advancement apply at all times, only when on the first class on
 *                                      an actor, or only on a class that is multi-classing?
 */
class BaseAdvancement extends SparseDataModel {

  /**
   * Name of this advancement type that will be stored in config and used for lookups.
   * @type {string}
   * @protected
   */
  static get typeName() {
    return this.name.replace(/Advancement$/, "");
  }

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

  /** @override */
  static defineSchema() {
    return {
      _id: new DocumentIdField$4({initial: () => foundry.utils.randomID()}),
      type: new StringField$T({
        required: true, initial: this.typeName, validate: v => v === this.typeName,
        validationError: `must be the same as the Advancement type name ${this.typeName}`
      }),
      configuration: new AdvancementDataField(this, {required: true}),
      value: new AdvancementDataField(this, {required: true}),
      level: new NumberField$A({
        integer: true, initial: this.metadata?.multiLevel ? undefined : 0, min: 0, label: "DND5E.Level"
      }),
      title: new StringField$T({initial: undefined, label: "DND5E.AdvancementCustomTitle"}),
      hint: new StringField$T({label: "DND5E.AdvancementHint"}),
      icon: new FilePathField({
        initial: undefined, categories: ["IMAGE"], label: "DND5E.AdvancementCustomIcon", base64: true
      }),
      classRestriction: new StringField$T({
        initial: undefined, choices: ["primary", "secondary"], label: "DND5E.AdvancementClassRestriction"
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    if ( source.configuration?.hint ) source.hint = source.configuration.hint;
    return source;
  }
}

/**
 * @import { PseudoDocumentsMetadata } from "../mixins/pseudo-document.mjs";
 */

/**
 * Error that can be thrown during the advancement update preparation process.
 */
class AdvancementError extends Error {
  constructor(...args) {
    super(...args);
    this.name = "AdvancementError";
  }
}

/**
 * Abstract base class which various advancement types can subclass.
 * @param {Item5e} item          Item to which this advancement belongs.
 * @param {object} [data={}]     Raw data stored in the advancement object.
 * @param {object} [options={}]  Options which affect DataModel construction.
 * @abstract
 */
class Advancement extends PseudoDocumentMixin(BaseAdvancement) {
  constructor(data, {parent=null, ...options}={}) {
    if ( parent instanceof Item ) parent = parent.system;
    super(data, {parent, ...options});

    /**
     * A collection of Application instances which should be re-rendered whenever this document is updated.
     * The keys of this object are the application ids and the values are Application instances. Each
     * Application in this object will have its render method called by {@link Document#render}.
     * @type {Object<Application>}
     */
    Object.defineProperty(this, "apps", {
      value: {},
      writable: false,
      enumerable: false
    });
  }

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

  /** @inheritDoc */
  _initialize(options) {
    super._initialize(options);
    return this.prepareData();
  }

  static ERROR = AdvancementError;

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

  /**
   * Information on how an advancement type is configured.
   *
   * @typedef {PseudoDocumentsMetadata} AdvancementMetadata
   * @property {object} dataModels
   * @property {DataModel} configuration  Data model used for validating configuration data.
   * @property {DataModel} value          Data model used for validating value data.
   * @property {number} order          Number used to determine default sorting order of advancement items.
   * @property {string} icon           Icon used for this advancement type if no user icon is specified.
   * @property {string} typeIcon       Icon used when selecting this advancement type during advancement creation.
   * @property {string} title          Title to be displayed if no user title is specified.
   * @property {string} hint           Description of this type shown in the advancement selection dialog.
   * @property {boolean} multiLevel    Can this advancement affect more than one level? If this is set to true,
   *                                   the level selection control in the configuration window is hidden and the
   *                                   advancement should provide its own implementation of `Advancement#levels`
   *                                   and potentially its own level configuration interface.
   * @property {Set<string>} validItemTypes  Set of types to which this advancement can be added. (deprecated)
   * @property {object} apps
   * @property {*} apps.config         Subclass of AdvancementConfig that allows for editing of this advancement type.
   * @property {*} apps.flow           Subclass of AdvancementFlow that is displayed while fulfilling this advancement.
   */

  /**
   * Configuration information for this advancement type.
   * @type {AdvancementMetadata}
   */
  static get metadata() {
    return {
      name: "Advancement",
      label: "DOCUMENT.DND5E.Advancement",
      order: 100,
      icon: "icons/svg/upgrade.svg",
      typeIcon: "icons/svg/upgrade.svg",
      title: game.i18n.localize("DND5E.AdvancementTitle"),
      hint: "",
      multiLevel: false,
      validItemTypes: new Set(["background", "class", "race", "subclass"]),
      apps: {
        config: AdvancementConfig$1,
        flow: AdvancementFlow
      }
    };
  }

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

  /**
   * Perform the pre-localization of this data model.
   */
  static localize() {
    Localization.localizeDataModel(this);
    if ( this.metadata.dataModels?.configuration ) {
      Localization.localizeDataModel(this.metadata.dataModels.configuration);
    }
    if ( this.metadata.dataModels?.value ) {
      Localization.localizeDataModel(this.metadata.dataModels.value);
    }
  }

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

  /**
   * List of levels in which this advancement object should be displayed. Will be a list of class levels if this
   * advancement is being applied to classes or subclasses, otherwise a list of character levels.
   * @returns {number[]}
   */
  get levels() {
    return this.level !== undefined ? [this.level] : [];
  }

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

  /**
   * Should this advancement be applied to a class based on its class restriction setting? This will always return
   * true for advancements that are not within an embedded class item.
   * @type {boolean}
   * @protected
   */
  get appliesToClass() {
    const originalClass = this.item.isOriginalClass;
    return !this.classRestriction
      || (this.classRestriction === "primary" && [true, null].includes(originalClass))
      || (this.classRestriction === "secondary" && !originalClass);
  }

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

  /**
   * Prepare data for the Advancement.
   */
  prepareData() {
    this.title = this.title || this.constructor.metadata.title;
    this.icon = this.icon || this.constructor.metadata.icon;
  }

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

  /**
   * Perform preliminary operations before an Advancement is created.
   * @param {object} data      The initial data object provided to the document creation request.
   * @returns {boolean|void}   A return value of false indicates the creation operation should be cancelled.
   * @protected
   */
  _preCreate(data) {
    if ( !["class", "subclass"].includes(this.item.type)
      || foundry.utils.hasProperty(data, "level")
      || this.constructor.metadata.multiLevel ) return;
    this.updateSource({level: 1});
  }

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

  /**
   * Has the player made choices for this advancement at the specified level?
   * @param {number} level  Level for which to check configuration.
   * @returns {boolean}     Have any available choices been made?
   */
  configuredForLevel(level) {
    return true;
  }

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

  /**
   * Value used for sorting this advancement at a certain level.
   * @param {number} level  Level for which this entry is being sorted.
   * @returns {string}      String that can be used for sorting.
   */
  sortingValueForLevel(level) {
    return `${this.constructor.metadata.order.paddedString(4)} ${this.titleForLevel(level)}`;
  }

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

  /**
   * Title displayed in advancement list for a specific level.
   * @param {number} level                           Level for which to generate a title.
   * @param {object} [options={}]
   * @param {boolean} [options.legacyDisplay=false]  Use legacy formatting?
   * @param {boolean} [options.configMode=false]     Is the advancement's item sheet in configuration mode? When in
   *                                                 config mode, the choices already made on this actor should not
   *                                                 be displayed.
   * @returns {string}                               HTML title with any level-specific information.
   */
  titleForLevel(level, options={}) {
    return this.title;
  }

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

  /**
   * Summary content displayed beneath the title in the advancement list.
   * @param {number} level                           Level for which to generate the summary.
   * @param {object} [options={}]
   * @param {boolean} [options.legacyDisplay=false]  Use legacy formatting?
   * @param {boolean} [options.configMode=false]     Is the advancement's item sheet in configuration mode? When in
   *                                                 config mode, the choices already made on this actor should not
   *                                                 be displayed.
   * @returns {string}                               HTML content of the summary.
   */
  summaryForLevel(level, options={}) {
    return "";
  }

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

  /**
   * Can an advancement of this type be added to the provided item?
   * @param {Item5e} item  Item to check against.
   * @returns {boolean}    Should this be enabled as an option on the `AdvancementSelection` dialog?
   */
  static availableForItem(item) {
    return true;
  }

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

  /** @inheritDoc */
  async delete(options={}) {
    if ( this.item.actor?.system.metadata?.supportsAdvancement
        && !game.settings.get("dnd5e", "disableAdvancements") ) {
      const manager = dnd5e.applications.advancement.AdvancementManager
        .forDeletedAdvancement(this.item.actor, this.item.id, this.id);
      if ( manager.steps.length ) return manager.render(true);
    }
    return super.delete(options);
  }

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

  /**
   * Locally apply this advancement to the actor.
   * @param {number} level   Level being advanced.
   * @param {object} data    Data from the advancement form.
   * @abstract
   */
  async apply(level, data) { }


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

  /**
   * Retrieves the data to pass to the apply method in order to apply this advancement automatically, if possible.
   * @param {number} level    Level being advanced.
   * @returns {object|false}  Data to pass to the apply method, or `false` if advancement requirers user intervention.
   */
  automaticApplicationValue(level) {
    return false;
  }

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

  /**
   * Locally apply this advancement from stored data, if possible. If stored data can not be restored for any reason,
   * throw an AdvancementError to display the advancement flow UI.
   * @param {number} level  Level being advanced.
   * @param {object} data   Data from `Advancement#reverse` needed to restore this advancement.
   * @abstract
   */
  async restore(level, data) { }

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

  /**
   * Locally remove this advancement's changes from the actor.
   * @param {number} level  Level being removed.
   * @returns {object}      Data that can be passed to the `Advancement#restore` method to restore this reversal.
   * @abstract
   */
  async reverse(level) { }

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

  /**
   * Fetch an item and create a clone with the proper flags.
   * @param {string} uuid  UUID of the item to fetch.
   * @param {string} [id]  Optional ID to use instead of a random one.
   * @returns {object|null}
   */
  async createItemData(uuid, id) {
    const source = await fromUuid(uuid);
    if ( !source ) return null;
    const { _stats } = game.items.fromCompendium(source);
    return source.clone({
      _stats,
      _id: id ?? foundry.utils.randomID(),
      "flags.dnd5e.sourceId": uuid,
      "flags.dnd5e.advancementOrigin": `${this.item.id}.${this.id}`
    }, { keepId: true }).toObject();
  }

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

  /**
   * Construct context menu options for this Activity.
   * @returns {ContextMenuEntry[]}
   */
  getContextMenuOptions() {
    if ( this.item.isOwner && !this.item[game.release.generation < 13 ? "compendium" : "collection"]?.locked ) return [
      {
        name: "DND5E.ADVANCEMENT.Action.Edit",
        icon: "<i class='fas fa-edit fa-fw'></i>",
        callback: () => this.sheet?.render(true)
      },
      {
        name: "DND5E.ADVANCEMENT.Action.Duplicate",
        icon: "<i class='fas fa-copy fa-fw'></i>",
        condition: li => this?.constructor.availableForItem(this.item),
        callback: () => {
          const createData = this.toObject();
          delete createData._id;
          this.item.createAdvancement(createData.type, createData, { renderSheet: false });
        }
      },
      {
        name: "DND5E.ADVANCEMENT.Action.Delete",
        icon: "<i class='fas fa-trash fa-fw'></i>",
        callback: () => this.deleteDialog()
      }
    ];

    return [{
      name: "DND5E.ADVANCEMENT.Action.View",
      icon: "<i class='fas fa-eye fa-fw'></i>",
      callback: () => this.sheet?.render(true)
    }];
  }

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

  /**
   * Handle context menu events on activities.
   * @param {Item5e} item         The Item the Activity belongs to.
   * @param {HTMLElement} target  The element the menu was triggered on.
   */
  static onContextMenu(item, target) {
    const { id } = target.closest("[data-id]")?.dataset ?? {};
    const advancement = item.advancement?.byId[id];
    if ( !advancement ) return;
    const menuItems = advancement.getContextMenuOptions();

    /**
     * A hook even that fires when the context menu for an Advancement is opened.
     * @function dnd5e.getItemAdvancementContext
     * @memberof hookEvents
     * @param {Advancement} advancement       The Advancement.
     * @param {HTMLElement} target            The element that menu was triggered on.
     * @param {ContextMenuEntry[]} menuItems  The context menu entries.
     */
    Hooks.callAll("dnd5e.getItemAdvancementContext", advancement, target, menuItems);
    ui.context.menuItems = menuItems;
  }
}

/**
 * Configuration application for ability score improvements.
 */
class AbilityScoreImprovementConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["ability-score-improvement"],
    actions: {
      decrease: AbilityScoreImprovementConfig.#adjustValue,
      increase: AbilityScoreImprovementConfig.#adjustValue,
      lock: AbilityScoreImprovementConfig.#lockValue
    }
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    details: {
      template: "systems/dnd5e/templates/advancement/ability-score-improvement-config-details.hbs"
    },
    scores: {
      template: "systems/dnd5e/templates/advancement/ability-score-improvement-config-scores.hbs",
      templates: ["systems/dnd5e/templates/advancement/parts/advancement-ability-score-control-v2.hbs"]
    }
  };

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [key, data]) => {
      if ( !this.advancement.canImprove(key) ) return obj;
      const fixed = this.advancement.configuration.fixed[key] ?? 0;
      const locked = this.advancement.configuration.locked.has(key);
      obj[key] = {
        key,
        name: `configuration.fixed.${key}`,
        label: data.label,
        locked: {
          value: locked,
          hint: `DND5E.ADVANCEMENT.AbilityScoreImprovement.FIELDS.locked.${locked ? "locked" : "unlocked"}`
        },
        value: fixed,
        canIncrease: true,
        canDecrease: true
      };
      return obj;
    }, {});

    context.points = {
      key: "points",
      name: "configuration.points",
      label: game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.FIELDS.points.label"),
      min: 0,
      value: this.advancement.configuration.points,
      canIncrease: true,
      canDecrease: this.advancement.configuration.points > 0
    };

    return context;
  }

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

  /**
   * Handle clicking the plus and minus buttons.
   * @this {AbilityScoreImprovementConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static #adjustValue(event, target) {
    const action = target.dataset.action;
    const input = target.closest("li").querySelector("input[type=number]");

    if ( action === "decrease" ) input.valueAsNumber -= 1;
    else if ( action === "increase" ) input.valueAsNumber += 1;

    this.submit();
  }

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

  /**
   * Handle locking or unlocking an ability.
   * @this {AbilityScoreImprovementConfig}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #lockValue(event, target) {
    const parent = target.closest("[data-score]");
    const { score } = parent.dataset;
    const input = parent.querySelector(`[name="configuration.locked.${score}"]`);
    input.value = input.value === "true" ? "false" : "true";
    this.submit();
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  async prepareConfigurationUpdate(configuration) {
    configuration.locked = Object.entries(configuration.locked).reduce((arr, [k, v]) => {
      if ( v ) arr.push(k);
      return arr;
    }, []);
    return configuration;
  }
}

/**
 * Inline application that presents the player with a choice between ability score improvement and taking a feat.
 */
class AbilityScoreImprovementFlow extends AdvancementFlow {

  /**
   * Player assignments to abilities.
   * @type {Object<string, number>}
   */
  assignments = {};

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

  /**
   * The dropped feat item.
   * @type {Item5e}
   */
  feat;

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

  /** @inheritDoc */
  static _customElements = super._customElements.concat(["dnd5e-checkbox"]);

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

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      dragDrop: [{ dropSelector: "form" }],
      template: "systems/dnd5e/templates/advancement/ability-score-improvement-flow.hbs"
    });
  }

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

  /** @inheritDoc */
  async retainData(data) {
    await super.retainData(data);
    this.assignments = this.retainedData.assignments ?? {};
    const featUuid = Object.values(this.retainedData.feat ?? {})[0];
    if ( featUuid ) this.feat = await fromUuid(featUuid);
    else if ( !foundry.utils.isEmpty(this.assignments) ) this.feat = { isASI: true };
  }

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

  /** @inheritDoc */
  async getData() {
    const points = {
      assigned: Object.keys(CONFIG.DND5E.abilities).reduce((assigned, key) => {
        if ( !this.advancement.canImprove(key) || this.advancement.configuration.locked.has(key) ) return assigned;
        return assigned + (this.assignments[key] ?? 0);
      }, 0),
      cap: this.advancement.configuration.cap ?? Infinity,
      total: this.advancement.configuration.points
    };
    points.available = points.total - points.assigned;

    const formatter = new Intl.NumberFormat(game.i18n.lang, { signDisplay: "always" });

    const lockImprovement = this.feat && !this.feat.isASI;
    const abilities = Object.entries(CONFIG.DND5E.abilities).reduce((obj, [key, data]) => {
      if ( !this.advancement.canImprove(key) ) return obj;
      const ability = this.advancement.actor.system.abilities[key];
      const assignment = this.assignments[key] ?? 0;
      const fixed = this.advancement.configuration.fixed[key] ?? 0;
      const locked = this.advancement.configuration.locked.has(key);
      const value = Math.min(ability.value + fixed + assignment, ability.max);
      const max = locked ? value : Math.min(value + points.available, ability.max);
      const min = Math.min(ability.value + fixed, ability.max);
      obj[key] = {
        key, max, min, value,
        name: `abilities.${key}`,
        label: data.label,
        initial: ability.value + fixed,
        delta: (value - ability.value) ? formatter.format(value - ability.value) : null,
        showDelta: true,
        isDisabled: lockImprovement,
        isLocked: !!locked || (ability.value >= ability.max),
        canIncrease: (value < max) && ((fixed + assignment) < points.cap) && !locked && !lockImprovement,
        canDecrease: (value > (ability.value + fixed)) && !locked && !lockImprovement
      };
      return obj;
    }, {});

    const modernRules = game.settings.get("dnd5e", "rulesVersion") === "modern";
    const pluralRules = new Intl.PluralRules(game.i18n.lang);
    return foundry.utils.mergeObject(super.getData(), {
      abilities, lockImprovement, points,
      feat: this.feat,
      pointCap: game.i18n.format(
        `DND5E.ADVANCEMENT.AbilityScoreImprovement.CapDisplay.${pluralRules.select(points.cap)}`, { points: points.cap }
      ),
      pointsRemaining: game.i18n.format(
        `DND5E.ADVANCEMENT.AbilityScoreImprovement.PointsRemaining.${pluralRules.select(points.available)}`,
        {points: points.available}
      ),
      showASIFeat: modernRules && this.advancement.allowFeat,
      showImprovement: !modernRules || !this.advancement.allowFeat || this.feat?.isASI,
      staticIncrease: !this.advancement.configuration.points
    });
  }

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

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".adjustment-button").click(this._onClickButton.bind(this));
    html.find("[data-action='browse']").click(this._onBrowseCompendium.bind(this));
    html.find("[data-action='delete']").click(this._onItemDelete.bind(this));
    html.find("[data-action='viewItem']").click(this._onClickFeature.bind(this));
  }

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

  /** @inheritDoc */
  _onChangeInput(event) {
    super._onChangeInput(event);
    const input = event.currentTarget;
    if ( input.name === "asi-selected" ) {
      if ( input.checked ) this.feat = { isASI: true };
      else {
        if ( this.feat?.isASI ) this.assignments = {};
        this.feat = null;
      }
    } else {
      const key = input.closest("[data-score]").dataset.score;
      if ( isNaN(input.valueAsNumber) ) this.assignments[key] = 0;
      else {
        this.assignments[key] = Math.min(
          Math.clamp(input.valueAsNumber, Number(input.min), Number(input.max)) - Number(input.dataset.initial),
          (this.advancement.configuration.cap - (this.advancement.configuration.fixed[key] ?? 0)) ?? Infinity
        );
      }
    }
    this.render();
  }

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

  /**
   * Handle opening the compendium browser and displaying the result.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  async _onBrowseCompendium(event) {
    event.preventDefault();
    const filters = {
      locked: {
        additional: { category: { feat: 1 } },
        types: new Set(["feat"])
      }
    };
    const result = await CompendiumBrowser.selectOne({ filters, tab: "feats" });
    if ( result ) this.feat = await fromUuid(result);
    this.render();
  }

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

  /**
   * Handle clicking the plus and minus buttons.
   * @param {Event} event  Triggering click event.
   */
  _onClickButton(event) {
    event.preventDefault();
    const action = event.currentTarget.dataset.action;
    const key = event.currentTarget.closest("li").dataset.score;

    this.assignments[key] ??= 0;
    if ( action === "decrease" ) this.assignments[key] -= 1;
    else if ( action === "increase" ) this.assignments[key] += 1;
    else return;

    this.render();
  }

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

  /**
   * Handle clicking on a feature during item grant to preview the feature.
   * @param {MouseEvent} event  The triggering event.
   * @protected
   */
  async _onClickFeature(event) {
    event.preventDefault();
    const uuid = event.target.closest("[data-uuid]")?.dataset.uuid;
    const item = await fromUuid(uuid);
    item?.sheet.render(true);
  }

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

  /**
   * Handle deleting a dropped feat.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onItemDelete(event) {
    event.preventDefault();
    this.feat = null;
    this.render();
  }

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

  /** @inheritDoc */
  async _updateObject(event, formData) {
    await this.advancement.apply(this.level, {
      type: (this.feat && !this.feat.isASI) ? "feat" : "asi",
      assignments: this.assignments,
      featUuid: this.feat?.uuid,
      retainedItems: this.retainedData?.retainedItems
    });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    if ( !this.advancement.allowFeat ) return false;

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData("text/plain"));
    } catch(err) {
      return false;
    }

    if ( data.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);

    if ( (item.type !== "feat") || (item.system.type.value !== "feat") ) {
      ui.notifications.error("DND5E.ADVANCEMENT.AbilityScoreImprovement.Warning.Type", {localize: true});
      return null;
    }

    // If a feat has a level pre-requisite, make sure it is less than or equal to current character level
    if ( (item.system.prerequisites?.level ?? -Infinity) > this.advancement.actor.system.details.level ) {
      ui.notifications.error(game.i18n.format("DND5E.ADVANCEMENT.AbilityScoreImprovement.Warning.Level", {
        level: item.system.prerequisites.level
      }));
      return null;
    }

    this.feat = item;
    this.render();
  }
}

/**
 * @callback MappingFieldInitialValueBuilder
 * @param {string} key       The key within the object where this new value is being generated.
 * @param {*} initial        The generic initial data provided by the contained model.
 * @param {object} existing  Any existing mapping data.
 * @returns {object}         Value to use as default for this key.
 */

/**
 * @typedef {DataFieldOptions} MappingFieldOptions
 * @property {string[]} [initialKeys]       Keys that will be created if no data is provided.
 * @property {MappingFieldInitialValueBuilder} [initialValue]  Function to calculate the initial value for a key.
 * @property {boolean} [initialKeysOnly=false]  Should the keys in the initialized data be limited to the keys provided
 *                                              by `options.initialKeys`?
 */

/**
 * A subclass of ObjectField that represents a mapping of keys to the provided DataField type.
 *
 * @param {DataField} model                    The class of DataField which should be embedded in this field.
 * @param {MappingFieldOptions} [options={}]   Options which configure the behavior of the field.
 * @property {string[]} [initialKeys]          Keys that will be created if no data is provided.
 * @property {MappingFieldInitialValueBuilder} [initialValue]  Function to calculate the initial value for a key.
 * @property {boolean} [initialKeysOnly=false]  Should the keys in the initialized data be limited to the keys provided
 *                                              by `options.initialKeys`?
 */
class MappingField extends foundry.data.fields.ObjectField {
  constructor(model, options) {
    if ( !(model instanceof foundry.data.fields.DataField) ) {
      throw new Error("MappingField must have a DataField as its contained element");
    }
    super(options);

    /**
     * The embedded DataField definition which is contained in this field.
     * @type {DataField}
     */
    this.model = model;
    model.parent = this;
  }

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

  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {
      initialKeys: null,
      initialValue: null,
      initialKeysOnly: false
    });
  }

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

  /** @inheritDoc */
  _cleanType(value, options) {
    Object.entries(value).forEach(([k, v]) => {
      if ( k.startsWith("-=") ) return;
      value[k] = this.model.clean(v, options);
    });
    return value;
  }

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

  /** @inheritDoc */
  getInitialValue(data) {
    let keys = this.initialKeys;
    const initial = super.getInitialValue(data);
    if ( !keys || !foundry.utils.isEmpty(initial) ) return initial;
    if ( !(keys instanceof Array) ) keys = Object.keys(keys);
    for ( const key of keys ) initial[key] = this._getInitialValueForKey(key);
    return initial;
  }

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

  /**
   * Get the initial value for the provided key.
   * @param {string} key       Key within the object being built.
   * @param {object} [object]  Any existing mapping data.
   * @returns {*}              Initial value based on provided field type.
   */
  _getInitialValueForKey(key, object) {
    const initial = this.model.getInitialValue();
    return this.initialValue?.(key, initial, object) ?? initial;
  }

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

  /** @override */
  _validateType(value, options={}) {
    if ( foundry.utils.getType(value) !== "Object" ) throw new Error("must be an Object");
    const errors = this._validateValues(value, options);
    if ( !foundry.utils.isEmpty(errors) ) {
      const failure = new foundry.data.validation.DataModelValidationFailure();
      failure.elements = Object.entries(errors).map(([id, failure]) => ({ id, failure }));
      throw failure.asError();
    }
  }

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

  /**
   * Validate each value of the object.
   * @param {object} value     The object to validate.
   * @param {object} options   Validation options.
   * @returns {Record<string, Error>}  An object of value-specific errors by key.
   */
  _validateValues(value, options) {
    const errors = {};
    for ( const [k, v] of Object.entries(value) ) {
      if ( k.startsWith("-=") ) continue;
      const error = this.model.validate(v, options);
      if ( error ) errors[k] = error;
    }
    return errors;
  }

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

  /** @override */
  initialize(value, model, options={}) {
    if ( !value ) return value;
    const obj = {};
    const initialKeys = (this.initialKeys instanceof Array) ? this.initialKeys : Object.keys(this.initialKeys ?? {});
    const keys = this.initialKeysOnly ? initialKeys : Object.keys(value);
    for ( const key of keys ) {
      const data = value[key] ?? this._getInitialValueForKey(key, value);
      obj[key] = this.model.initialize(data, model, options);
    }
    return obj;
  }

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

  /** @inheritDoc */
  _getField(path) {
    if ( path.length === 0 ) return this;
    else if ( path.length === 1 ) return this.model;
    path.shift();
    return this.model._getField(path);
  }
}

const { NumberField: NumberField$z, SetField: SetField$q, StringField: StringField$S } = foundry.data.fields;

/**
 * Data model for the Ability Score Improvement advancement configuration.
 *
 * @property {number} cap                    Maximum number of points that can be assigned to a single score.
 * @property {Set<string>} locked            Abilities that cannot be changed by this advancement.
 * @property {Object<string, number>} fixed  Number of points automatically assigned to a certain score.
 * @property {number} points                 Number of points that can be assigned to any score.
 */
class AbilityScoreImprovementConfigurationData extends foundry.abstract.DataModel {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.AbilityScoreImprovement"];

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

  /** @inheritDoc */
  static defineSchema() {
    return {
      cap: new NumberField$z({ integer: true, min: 1, initial: 2 }),
      fixed: new MappingField(new NumberField$z({ nullable: false, integer: true, initial: 0 })),
      locked: new SetField$q(new StringField$S()),
      points: new NumberField$z({ integer: true, min: 0, initial: 0 })
    };
  }
}

/**
 * Data model for the Ability Score Improvement advancement value.
 *
 * @property {string} type             When on a class, whether the player chose ASI or a Feat.
 * @property {Object<string, number>}  Points assigned to individual scores.
 * @property {Object<string, string>}  Feat that was selected.
 */
class AbilityScoreImprovementValueData extends SparseDataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      type: new StringField$S({ required: true, initial: "asi", choices: ["asi", "feat"] }),
      assignments: new MappingField(new NumberField$z({
        nullable: false, integer: true
      }), { required: false, initial: undefined }),
      feat: new MappingField(new StringField$S(), { required: false, initial: undefined, label: "DND5E.Feature.Feat" })
    };
  }
}

/**
 * Advancement that presents the player with the option of improving their ability scores or selecting a feat.
 */
class AbilityScoreImprovementAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: AbilityScoreImprovementConfigurationData,
        value: AbilityScoreImprovementValueData
      },
      order: 20,
      icon: "icons/magic/symbols/star-solid-gold.webp",
      typeIcon: "systems/dnd5e/icons/svg/ability-score-improvement.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.Hint"),
      apps: {
        config: AbilityScoreImprovementConfig,
        flow: AbilityScoreImprovementFlow
      }
    });
  }

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

  /** @inheritDoc */
  _preCreate(data) {
    if ( super._preCreate(data) === false ) return false;
    if ( this.item.type !== "class" || foundry.utils.hasProperty(data, "configuration.points") ) return;
    this.updateSource({"configuration.points": 2});
  }

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

  /**
   * Does this advancement allow feats, or just ability score improvements?
   * @type {boolean}
   */
  get allowFeat() {
    return (this.item.type === "class") && (game.settings.get("dnd5e", "allowFeats")
      || game.settings.get("dnd5e", "rulesVersion") === "modern");
  }

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

  /**
   * Information on the ASI points available.
   * @type {{ assigned: number, total: number }}
   */
  get points() {
    return {
      assigned: Object.entries(this.value.assignments ?? {}).reduce((n, [abl, c]) => {
        if ( this.canImprove(abl) ) n += c;
        return n;
      }, 0),
      total: this.configuration.points + Object.entries(this.configuration.fixed).reduce((t, [abl, v]) => {
        if ( this.canImprove(abl) ) t += v;
        return t;
      }, 0)
    };
  }

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

  /**
   * Is this ability allowed to be improved?
   * @param {string} ability  The ability key.
   * @returns {boolean}
   */
  canImprove(ability) {
    return CONFIG.DND5E.abilities[ability]?.improvement !== false;
  }

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

  /** @inheritDoc */
  titleForLevel(level, { configMode=false }={}) {
    if ( this.value.selected !== "feat" ) return this.title;
    return game.i18n.localize("DND5E.Feature.Feat");
  }

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

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    const formatter = new Intl.NumberFormat(game.i18n.lang, { signDisplay: "always" });
    if ( configMode ) {
      const entries = Object.entries(this.configuration.fixed).map(([key, value]) => {
        if ( !value ) return null;
        const name = CONFIG.DND5E.abilities[key]?.label ?? key;
        return `<span class="tag">${name} <strong>${formatter.format(value)}</strong></span>`;
      });
      if ( this.configuration.points ) entries.push(`<span class="tag">${
        game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.FIELDS.points.label")}: <strong>${
        this.configuration.points}</strong></span>`
      );
      return entries.filterJoin("\n");
    }

    else if ( (this.value.type === "feat") && this.value.feat ) {
      const id = Object.keys(this.value.feat)[0];
      const feat = this.actor.items.get(id);
      if ( feat ) return feat.toAnchor({classes: ["content-link"]}).outerHTML;
    }

    else if ( (this.value.type === "asi") && this.value.assignments ) {
      return Object.entries(this.value.assignments).reduce((html, [key, value]) => {
        const name = CONFIG.DND5E.abilities[key]?.label ?? key;
        html += `<span class="tag">${name} <strong>${formatter.format(value)}</strong></span>\n`;
        return html;
      }, "");
    }

    return "";
  }

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

  /** @inheritDoc */
  async apply(level, data) {
    if ( data.type === "asi" ) {
      const assignments = Object.keys(CONFIG.DND5E.abilities).reduce((obj, key) => {
        obj[key] = (this.configuration.fixed[key] ?? 0) + (data.assignments[key] ?? 0);
        return obj;
      }, {});
      const updates = {};
      for ( const key of Object.keys(assignments) ) {
        const ability = this.actor.system.abilities[key];
        const source = this.actor.system.toObject().abilities[key] ?? {};
        if ( !ability || !this.canImprove(key) ) continue;
        assignments[key] = Math.min(assignments[key], ability.max - source.value);
        if ( assignments[key] ) updates[`system.abilities.${key}.value`] = source.value + assignments[key];
        else delete assignments[key];
      }
      data.assignments = assignments;
      data.feat = null;
      this.actor.updateSource(updates);
    }

    else {
      let itemData = data.retainedItems?.[data.featUuid];
      if ( !itemData ) itemData = await this.createItemData(data.featUuid);
      data.assignments = null;
      if ( itemData ) {
        data.feat = { [itemData._id]: data.featUuid };
        this.actor.updateSource({items: [itemData]});
      }
    }

    delete data.featUuid;
    delete data.retainedItems;
    this.updateSource({value: data});
  }

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

  /** @inheritDoc */
  restore(level, data) {
    data.featUuid = Object.values(data.feat ?? {})[0];
    this.apply(level, data);
  }

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

  /** @inheritDoc */
  reverse(level) {
    const source = this.value.toObject();

    if ( this.value.type === "asi" ) {
      const updates = {};
      for ( const [key, change] of Object.entries(this.value.assignments ?? {}) ) {
        const ability = this.actor.system.toObject().abilities[key];
        if ( !ability || !this.canImprove(key) ) continue;
        updates[`system.abilities.${key}.value`] = ability.value - change;
        source.assignments[key] -= (this.configuration.fixed[key] ?? 0);
      }
      this.actor.updateSource(updates);
    }

    else {
      const [id, uuid] = Object.entries(this.value.feat ?? {})[0] ?? [];
      const item = this.actor.items.get(id);
      if ( item ) source.retainedItems = {[uuid]: item.toObject()};
      this.actor.items.delete(id);
    }

    this.updateSource({ "value.assignments": null, "value.feat": null });
    return source;
  }
}

/**
 * Configuration application for hit points.
 */
let HitPointsConfig$1 = class HitPointsConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["hit-points"]
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    hitPoints: {
      template: "systems/dnd5e/templates/advancement/hit-points-config.hbs"
    }
  };

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.hitDie = this.advancement.hitDie;
    return context;
  }
};

/**
 * Inline application that presents hit points selection upon level up.
 */
class HitPointsFlow extends AdvancementFlow {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/hit-points-flow.hbs"
    });
  }

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

  /** @inheritDoc */
  getData() {
    const source = this.retainedData ?? this.advancement.value;
    const value = source[this.level];

    // If value is empty, `useAverage` should default to the value selected at the previous level
    let useAverage = value === "avg";
    if ( !value ) {
      const lastValue = source[this.level - 1];
      if ( lastValue === "avg" ) useAverage = true;
    }

    return foundry.utils.mergeObject(super.getData(), {
      isFirstClassLevel: (this.level === 1) && this.advancement.item.isOriginalClass,
      hitDie: this.advancement.hitDie,
      dieValue: this.advancement.hitDieValue,
      data: {
        value: Number.isInteger(value) ? value : "",
        useAverage
      }
    });
  }

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

  /** @inheritDoc */
  activateListeners(html) {
    this.form.querySelector(".average-checkbox")?.addEventListener("change", event => {
      this.form.querySelector(".roll-result").disabled = event.target.checked;
      this.form.querySelector(".roll-button").disabled = event.target.checked;
      this._updateRollResult();
    });
    this.form.querySelector(".roll-button")?.addEventListener("click", async () => {
      const roll = await this.advancement.actor.rollClassHitPoints(this.advancement.item);
      this.form.querySelector(".roll-result").value = roll.total;
    });
    this._updateRollResult();
  }

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

  /**
   * Update the roll result display when the average result is taken.
   * @protected
   */
  _updateRollResult() {
    if ( !this.form.elements.useAverage?.checked ) return;
    this.form.elements.value.value = (this.advancement.hitDieValue / 2) + 1;
  }

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

  /** @inheritDoc */
  _updateObject(event, formData) {
    let value;
    if ( formData.useMax ) value = "max";
    else if ( formData.useAverage ) value = "avg";
    else if ( Number.isInteger(formData.value) ) value = parseInt(formData.value);

    if ( value !== undefined ) return this.advancement.apply(this.level, { [this.level]: value });

    this.form.querySelector(".rollResult")?.classList.add("error");
    const errorType = formData.value ? "Invalid" : "Empty";
    throw new Advancement.ERROR(game.i18n.localize(`DND5E.ADVANCEMENT.HitPoints.Warning.${errorType}`));
  }

}

/**
 * Advancement that presents the player with the option to roll hit points at each level or select the average value.
 * Keeps track of player hit point rolls or selection for each class level. **Can only be added to classes and each
 * class can only have one.**
 */
class HitPointsAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      order: 10,
      icon: "icons/magic/life/heart-pink.webp",
      typeIcon: "systems/dnd5e/icons/svg/hit-points.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.HitPoints.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.HitPoints.Hint"),
      multiLevel: true,
      apps: {
        config: HitPointsConfig$1,
        flow: HitPointsFlow
      }
    });
  }

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

  /**
   * The amount gained if the average is taken.
   * @type {number}
   */
  get average() {
    return (this.hitDieValue / 2) + 1;
  }

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

  /** @inheritDoc */
  get levels() {
    return Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1);
  }

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

  /**
   * Shortcut to the hit die used by the class.
   * @returns {string}
   */
  get hitDie() {
    if ( this.actor?.type === "npc" ) return `d${this.actor.system.attributes.hd.denomination}`;
    return this.item.system.hd.denomination;
  }

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

  /**
   * The face value of the hit die used.
   * @returns {number}
   */
  get hitDieValue() {
    return Number(this.hitDie.substring(1));
  }

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

  /** @inheritDoc */
  configuredForLevel(level) {
    return this.valueForLevel(level) !== null;
  }

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

  /** @inheritDoc */
  titleForLevel(level, { configMode=false, legacyDisplay=false }={}) {
    const hp = this.valueForLevel(level);
    if ( !hp || configMode || !legacyDisplay ) return this.title;
    return `${this.title}: <strong>${hp}</strong>`;
  }

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

  /**
   * Hit points given at the provided level.
   * @param {number} level   Level for which to get hit points.
   * @returns {number|null}  Hit points for level or null if none have been taken.
   */
  valueForLevel(level) {
    return this.constructor.valueForLevel(this.value, this.hitDieValue, level);
  }

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

  /**
   * Hit points given at the provided level.
   * @param {object} data         Contents of `value` used to determine this value.
   * @param {number} hitDieValue  Face value of the hit die used by this advancement.
   * @param {number} level        Level for which to get hit points.
   * @returns {number|null}       Hit points for level or null if none have been taken.
   */
  static valueForLevel(data, hitDieValue, level) {
    const value = data[level];
    if ( !value ) return null;

    if ( value === "max" ) return hitDieValue;
    if ( value === "avg" ) return (hitDieValue / 2) + 1;
    return value;
  }

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

  /**
   * Total hit points provided by this advancement.
   * @returns {number}  Hit points currently selected.
   */
  total() {
    return Object.keys(this.value).reduce((total, level) => total + this.valueForLevel(parseInt(level)), 0);
  }

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

  /**
   * Total hit points taking the provided ability modifier into account, with a minimum of 1 per level.
   * @param {number} mod  Modifier to add per level.
   * @returns {number}    Total hit points plus modifier.
   */
  getAdjustedTotal(mod) {
    return Object.keys(this.value).reduce((total, level) => {
      return total + Math.max(this.valueForLevel(parseInt(level)) + mod, 1);
    }, 0);
  }

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

  /** @inheritDoc */
  static availableForItem(item) {
    return !item.advancement.byType.HitPoints?.length;
  }

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

  /**
   * Add the ability modifier and any bonuses to the provided hit points value to get the number to apply.
   * @param {number} value  Hit points taken at a given level.
   * @returns {number}      Hit points adjusted with ability modifier and per-level bonuses.
   */
  #getApplicableValue(value) {
    const abilityId = CONFIG.DND5E.defaultAbilities.hitPoints || "con";
    value = Math.max(value + (this.actor.system.abilities[abilityId]?.mod ?? 0), 1);
    value += simplifyBonus(this.actor.system.attributes.hp.bonuses?.level, this.actor.getRollData());
    return value;
  }

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

  /** @inheritDoc */
  apply(level, data) {
    let value = this.constructor.valueForLevel(data, this.hitDieValue, level);
    if ( value === undefined ) return;
    this.actor.updateSource({
      "system.attributes.hp.value": this.actor.system.attributes.hp.value + this.#getApplicableValue(value)
    });
    this.updateSource({ value: data });
  }

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

  /** @override */
  automaticApplicationValue(level) {
    if ( (level === 1) && this.item.isOriginalClass ) return { [level]: "max" };
    if ( this.value[level - 1] === "avg" ) return { [level]: "avg" };
    return false;
  }

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

  /** @inheritDoc */
  restore(level, data) {
    this.apply(level, data);
  }

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

  /** @inheritDoc */
  reverse(level) {
    let value = this.valueForLevel(level);
    if ( value === undefined ) return;
    this.actor.updateSource({
      "system.attributes.hp.value": this.actor.system.attributes.hp.value - this.#getApplicableValue(value)
    });
    const source = { [level]: this.value[level] };
    this.updateSource({ [`value.-=${level}`]: null });
    return source;
  }
}

/**
 * Configuration application for item choices.
 */
class ItemChoiceConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["item-choice", "three-column"],
    dropKeyPath: "pool",
    position: {
      width: 800
    }
  };

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

  /** @inheritDoc */
  static PARTS = {
    config: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    },
    details: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/item-choice-config-details.hbs"
    },
    spellConfig: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-spell-config-section.hbs"
    },
    items: {
      container: { classes: ["column-container"], id: "column-center" },
      template: "systems/dnd5e/templates/advancement/item-choice-config-items.hbs"
    },
    levels: {
      container: { classes: ["column-container"], id: "column-right" },
      template: "systems/dnd5e/templates/advancement/item-choice-config-levels.hbs"
    }
  };

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.items = this.advancement.configuration.pool.map(data => ({
      data,
      fields: this.advancement.configuration.schema.fields.pool.element.fields,
      index: fromUuidSync(data.uuid)
    }));

    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }));
    context.choices = context.levels.reduce((obj, { value, label }) => {
      obj[value] = { label, ...this.advancement.configuration.choices[value] };
      return obj;
    }, {});
    context.levelRestrictionOptions = [
      { value: "", label: "" },
      {
        value: "available",
        label: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.FIELDS.restriction.level.Available")
      },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.spellLevels).map(([value, label]) => ({ value, label }))
    ];
    context.showContainerWarning = context.items.some(i => i.index?.type === "container");
    context.showSpellConfig = this.advancement.configuration.type === "spell";
    context.showRequireSpellSlot = !this.advancement.configuration.spell?.preparation
      || CONFIG.DND5E.spellPreparationModes[this.advancement.configuration.spell?.preparation]?.upcast;
    context.typeOptions = [
      { value: "", label: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.FIELDS.type.Any") },
      { rule: true },
      ...this.advancement.constructor.VALID_TYPES
        .map(value => ({ value, label: game.i18n.localize(CONFIG.Item.typeLabels[value]) }))
    ];

    if ( this.advancement.configuration.type === "feat" ) {
      const selectedType = CONFIG.DND5E.featureTypes[this.advancement.configuration.restriction.type];
      context.typeRestriction = {
        typeLabel: game.i18n.localize("DND5E.ItemFeatureType"),
        typeOptions: [
          { value: "", label: "" },
          ...Object.entries(CONFIG.DND5E.featureTypes).map(([value, { label }]) => ({ value, label }))
        ],
        subtypeLabel: game.i18n.format("DND5E.ItemFeatureSubtype", {category: selectedType?.label}),
        subtypeOptions: selectedType?.subtypes ? [
          { value: "", label: "" },
          ...Object.entries(selectedType.subtypes).map(([value, label]) => ({ value, label }))
        ] : null
      };
    }

    return context;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async prepareConfigurationUpdate(configuration) {
    if ( configuration.choices ) configuration.choices = this.constructor._cleanedObject(configuration.choices);
    if ( configuration.spell ) configuration.spell.ability ??= [];
    if ( configuration.pool ) configuration.pool = Object.values(configuration.pool);

    // Ensure items are still valid if type restriction or spell restriction are changed
    const pool = [];
    for ( const item of (configuration.pool ?? this.advancement.configuration.pool) ) {
      if ( this.advancement._validateItemType(await fromUuid(item.uuid), {
        type: configuration.type, restriction: configuration.restriction ?? {}, strict: false
      }) ) pool.push(item);
    }
    configuration.pool = pool;

    return configuration;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _validateDroppedItem(event, item) {
    this.advancement._validateItemType(item);
  }
}

const { BooleanField: BooleanField$v } = foundry.data.fields;

/**
 * Dialog with shared resting functionality.
 */
class BaseRestDialog extends Dialog5e {
  constructor(options={}, _dialogData={}, _options={}) {
    if ( options instanceof Actor ) {
      foundry.utils.logCompatibilityWarning(
        "The rest dialogs now take a single options object with `document` and `config` options.",
        { since: "DnD5e 4.2", until: "DnD5e 4.4" }
      );
      options = { ..._options, data: _dialogData, document: options };
    }
    super(options);
    this.actor = options.document;
    this.#config = options.config;
  }

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

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["rest"],
    config: null,
    document: null,
    form: {
      handler: BaseRestDialog.#handleFormSubmission
    },
    position: {
      width: 380
    }
  };

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

  /**
   * The actor being rested.
   * @type {Actor5e}
   */
  actor;

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

  /**
   * The rest configuration.
   * @type {RestConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

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

  /**
   * Should the user be prompted as to whether a new day has occurred?
   * @type {boolean}
   */
  get promptNewDay() {
    const duration = CONFIG.DND5E.restTypes[this.config.type]
      ?.duration?.[game.settings.get("dnd5e", "restVariant")] ?? 0;
    // Only prompt if rest is longer than 10 minutes and less than 24 hours
    return (duration > 10) && (duration < 1440);
  }

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

  /**
   * Was the rest button pressed?
   * @type {boolean}
   */
  #rested = false;

  get rested() {
    return this.#rested;
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      actor: this.actor,
      config: this.config,
      fields: [],
      result: this.result,
      hd: this.actor.system.attributes?.hd,
      hp: this.actor.system.attributes?.hp,
      isGroup: this.actor.type === "group",
      variant: game.settings.get("dnd5e", "restVariant")
    };
    if ( this.promptNewDay ) context.fields.push({
      field: new BooleanField$v({
        label: game.i18n.localize("DND5E.REST.NewDay.Label"),
        hint: game.i18n.localize("DND5E.REST.NewDay.Hint")
      }),
      input: context.inputs.createCheckboxInput,
      name: "newDay",
      value: context.config.newDay
    });
    return context;
  }

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

  /**
   * Handle submission of the dialog using the form buttons.
   * @this {BaseRestDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    foundry.utils.mergeObject(this.config, formData.object);
    this.#rested = true;
    await this.close();
  }

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

  /**
   * A helper to handle displaying and responding to the dialog.
   * @param {Actor5e} actor              The actor that is resting.
   * @param {RestConfiguration}  config  Configuration information for the rest.
   * @returns {Promise<RestConfiguration>}
   */
  static async configure(actor, config) {
    return new Promise((resolve, reject) => {
      const app = new this({
        config,
        buttons: [
          {
            default: true,
            icon: "fa-solid fa-bed",
            label: game.i18n.localize("DND5E.REST.Label"),
            name: "rest",
            type: "submit"
          }
        ],
        document: actor
      });
      app.addEventListener("close", () => app.rested ? resolve(app.config) : reject(), { once: true });
      app.render({ force: true });
    });
  }
}

const { BooleanField: BooleanField$u } = foundry.data.fields;

/**
 * Dialog for configuring a short rest.
 */
class ShortRestDialog extends BaseRestDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["short-rest"],
    actions: {
      rollHitDie: ShortRestDialog.#rollHitDie
    },
    window: {
      title: "DND5E.REST.Short.Label"
    }
  };

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

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/actors/rest/short-rest.hbs"
    }
  };

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

  /**
   * Currently selected hit dice denomination.
   * @type {string}
   */
  #denom;

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.autoRoll = new BooleanField$u({
      label: game.i18n.localize("DND5E.REST.HitDice.AutoSpend.Label"),
      hint: game.i18n.localize("DND5E.REST.HitDice.AutoSpend.Hint")
    });

    if ( this.actor.type === "npc" ) {
      const hd = this.actor.system.attributes.hd;
      context.hitDice = {
        canRoll: hd.value > 0,
        denomination: `d${hd.denomination}`,
        options: [{
          value: `d${hd.denomination}`,
          label: `d${hd.denomination} (${game.i18n.format("DND5E.HITDICE.Available", { number: hd.value })})`
        }]
      };
    }

    else if ( foundry.utils.hasProperty(this.actor, "system.attributes.hd") ) {
      context.hitDice = {
        canRoll: this.actor.system.attributes.hd.value > 0,
        options: Object.entries(this.actor.system.attributes.hd.bySize).map(([value, number]) => ({
          value, label: `${value} (${game.i18n.format("DND5E.HITDICE.Available", { number })})`, number
        }))
      };
      context.denomination = (this.actor.system.attributes.hd.bySize[this.#denom] > 0)
        ? this.#denom : context.hitDice.options.find(o => o.number > 0)?.value;
    }

    else context.fields.unshift({
      field: context.autoRoll,
      input: context.inputs.createCheckboxInput,
      name: "autoHD",
      value: context.config.autoHD
    });

    return context;
  }

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

  /**
   * Handle rolling a hit die.
   * @this {ShortRestDialog}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #rollHitDie(event, target) {
    this.#denom = this.form.denom.value;
    await this.actor.rollHitDie({ denomination: this.#denom });
    foundry.utils.mergeObject(this.config, new FormDataExtended(this.form).object);
    this.render();
  }

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

  /**
   * A helper constructor function which displays the Short Rest dialog and returns a Promise once its workflow has
   * been resolved.
   * @param {object} [options={}]
   * @param {Actor5e} [options.actor]  Actor that is taking the short rest.
   * @returns {Promise}                Promise that resolves when the rest is completed or rejects when canceled.
   */
  static async shortRestDialog({ actor } = {}) {
    foundry.utils.logCompatibilityWarning(
      "The `shortRestDialog` method on `ShortRestDialog` has been renamed `configure`.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4" }
    );
    return this.configure(actor, { type: "short" });
  }
}

const { BooleanField: BooleanField$t } = foundry.data.fields;

/**
 * Dialog for configuring a long rest.
 */
class LongRestDialog extends BaseRestDialog {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["long-rest"],
    window: {
      title: "DND5E.REST.Long.Label"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/actors/rest/long-rest.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    const { enabled } = game.settings.get("dnd5e", "bastionConfiguration");
    if ( game.user.isGM && context.isGroup && enabled ) context.fields.unshift({
      field: new BooleanField$t({ label: game.i18n.localize("DND5E.Bastion.Action.BastionTurn") }),
      input: context.inputs.createCheckboxInput,
      name: "advanceBastionTurn",
      value: context.config.advanceBastionTurn
    });

    return context;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * A helper constructor function which displays the Long Rest confirmation dialog and returns a Promise once its
   * workflow has been resolved.
   * @param {object} [options={}]
   * @param {Actor5e} [options.actor]  Actor that is taking the long rest.
   * @returns {Promise}                Promise that resolves when the rest is completed or rejects when canceled.
   */
  static async longRestDialog({ actor } = {}) {
    foundry.utils.logCompatibilityWarning(
      "The `longRestDialog` method on `LongRestDialog` has been renamed `configure`.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4" }
    );
    return this.configure(actor, { type: "long" });
  }
}

/**
 * Description for a single part of a property attribution.
 * @typedef {object} AttributionDescription
 * @property {string} label               Descriptive label that will be displayed. If the label is in the form
 *                                        of an @ property, the system will try to turn it into a human-readable label.
 * @property {number} mode                Application mode for this step as defined in
 *                           [CONST.ACTIVE_EFFECT_MODES](https://foundryvtt.com/api/module-constants.html#.ACTIVE_EFFECT_MODES).
 * @property {number} value               Value of this step.
 * @property {ActiveEffect5e} [document]  Active effect applying this attribution, if any.
 */

/**
 * Interface for viewing what factors went into determining a specific property.
 *
 * @param {Document} object                        The Document that owns the property being attributed.
 * @param {AttributionDescription[]} attributions  An array of all the attribution data.
 * @param {string} property                        Dot separated path to the property.
 * @param {object} [options={}]                    Application rendering options.
 */
class PropertyAttribution extends Application5e {
  constructor(object, attributions, property, options={}) {
    super(options);
    this.object = object;
    this.attributions = attributions;
    this.property = property;
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["property-attribution"],
    window: {
      frame: false,
      positioned: false
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    attribution: {
      template: "systems/dnd5e/templates/apps/property-attribution.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Prepare tooltip contents.
   * @returns {Promise<string>}
   */
  async renderTooltip() {
    await this.render({ force: true });
    return this.element.innerHTML;
  }

  /* -------------------------------------------- */

  /** @override */
  _insertElement(element) {}

  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const property = foundry.utils.getProperty(this.object.system, this.property);
    let total;
    if ( Number.isNumeric(property)) total = property;
    else if ( typeof property === "object" && Number.isNumeric(property.value) ) total = property.value;
    const sources = foundry.utils.duplicate(this.attributions);
    return {
      caption: game.i18n.localize(this.options.title),
      sources: sources.map(entry => {
        if ( entry.label.startsWith("@") ) entry.label = this.getPropertyLabel(entry.label.slice(1));
        if ( (entry.mode === CONST.ACTIVE_EFFECT_MODES.ADD) && (entry.value < 0) ) {
          entry.negative = true;
          entry.value = entry.value * -1;
        }
        return entry;
      }),
      total: total
    };
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Produce a human-readable and localized name for the provided property.
   * @param {string} property  Dot separated path to the property.
   * @returns {string}         Property name for display.
   */
  getPropertyLabel(property) {
    const parts = property.split(".");
    if ( parts[0] === "abilities" && parts[1] ) {
      return CONFIG.DND5E.abilities[parts[1]]?.label ?? property;
    } else if ( (property === "attributes.ac.dex") && CONFIG.DND5E.abilities.dex ) {
      return CONFIG.DND5E.abilities.dex.label;
    } else if ( (parts[0] === "prof") || (property === "attributes.prof") ) {
      return game.i18n.localize("DND5E.Proficiency");
    }
    return property;
  }
}

const { SetField: SetField$p, StringField: StringField$R } = foundry.data.fields;

/**
 * @typedef {Set<string>} ActivationsData
 */

/**
 * A field for storing relative UUIDs to activations on the actor.
 */
class ActivationsField extends SetField$p {
  constructor() {
    super(new StringField$R());
  }

  /* -------------------------------------------- */

  /**
   * Find any activity relative UUIDs on this actor that can be used during a set of periods.
   * @param {Actor5e} actor
   * @param {string[]} periods
   * @returns {string[]}
   */
  static getActivations(actor, periods) {
    return actor.items
      .map(i => i.system.activities?.filter(a => periods.includes(a.activation?.type)).map(a => a.relativeUUID) ?? [])
      .flat();
  }

  /* -------------------------------------------- */

  /**
   * Prepare activations for display on chat card.
   * @this {ActivationsData}
   * @param {Actor5e} actor  Actor to which this activations can be used.
   * @returns {Activity[]}
   */
  static processActivations(actor) {
    return Array.from(this)
      .map(uuid => fromUuidSync(uuid, { relative: actor, strict: false }))
      .filter(_ => _)
      .sort((lhs, rhs) => (lhs.item.sort - rhs.item.sort) || (lhs.sort - rhs.sort));
  }
}

const { ArrayField: ArrayField$h, NumberField: NumberField$y, SchemaField: SchemaField$D, StringField: StringField$Q } = foundry.data.fields;

/**
 * @typedef ActorDeltasData
 * @property {IndividualDeltaData[]} actor                 Changes for the actor.
 * @property {Record<string, IndividualDeltaData[]>} item  Changes for each item grouped by ID.
 */

/**
 * @typedef DeltaDisplayContext
 * @property {string} type              Type of document to which the delta applies.
 * @property {string} delta             The formatted numeric change.
 * @property {Actor5e|Item5e} document  The document to which the delta applies.
 * @property {string} label             The formatted label for the attribute.
 * @property {Roll[]} [rolls]           Any rolls associated with the delta.
 */

/**
 * A field for storing deltas made to an actor or embedded items.
 */
class ActorDeltasField extends SchemaField$D {
  constructor() {
    super({
      actor: new ArrayField$h(new IndividualDeltaField()),
      item: new MappingField(new ArrayField$h(new IndividualDeltaField()))
    });
  }

  /* -------------------------------------------- */

  /**
   * Calculate delta information for an actor document from the given updates.
   * @param {Actor5e} actor                              Actor for which to calculate the deltas.
   * @param {{ actor: object, item: object[] }} updates  Updates to apply to the actor and contained items.
   * @returns {ActorDeltasData}
   */
  static getDeltas(actor, updates) {
    return {
      actor: IndividualDeltaField.getDeltas(actor, updates.actor),
      item: updates.item.reduce((obj, { _id, ...changes }) => {
        const deltas = IndividualDeltaField.getDeltas(actor.items.get(_id), changes);
        if ( deltas.length ) obj[_id] = deltas;
        return obj;
      }, {})
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare deltas for display in a chat message.
   * @this {ActorDeltasData}
   * @param {Actor5e} actor   Actor to which this delta applies.
   * @param {Roll[]} [rolls]  Rolls that may be associated with a delta.
   * @returns {DeltaDisplayContext[]}
   */
  static processDeltas(actor, rolls=[]) {
    return [
      ...this.actor.map(d => IndividualDeltaField.processDelta.call(d, actor, rolls
        .filter(r => !r.options.delta?.item && (r.options.delta?.keyPath === d.keyPath)))),
      ...Object.entries(this.item).flatMap(([id, deltas]) =>
        deltas.map(d => IndividualDeltaField.processDelta.call(d, actor.items.get(id), rolls
          .filter(r => (r.options.delta?.item === id) && (r.options.delta?.keyPath === d.keyPath))))
      )
    ];
  }
}

/**
 * @typedef IndividualDeltaData
 * @property {number} delta    The change in the specified field.
 * @property {string} keyPath  Path to the changed field on the document.
 */

/**
 * A field that stores a delta for an individual property on an actor or item.
 */
class IndividualDeltaField extends SchemaField$D {
  constructor() {
    super({ delta: new NumberField$y(), keyPath: new StringField$Q() });
  }

  /* -------------------------------------------- */

  /**
   * Calculate delta information for a document from the given updates.
   * @param {DataModel} dataModel      Document for which to calculate the deltas.
   * @param {object} updates           Updates that are to be applied.
   * @returns {IndividualDeltaData[]}
   */
  static getDeltas(dataModel, updates) {
    updates = foundry.utils.flattenObject(updates);
    const deltas = [];
    for ( const [keyPath, value] of Object.entries(updates) ) {
      let currentValue;
      if ( keyPath.startsWith("system.activities") ) {
        const [id, ...kp] = keyPath.slice(18).split(".");
        currentValue = foundry.utils.getProperty(dataModel.system.activities?.get(id) ?? {}, kp.join("."));
      }
      else currentValue = foundry.utils.getProperty(dataModel, keyPath);

      const delta = value - currentValue;
      if ( delta && !Number.isNaN(delta) ) deltas.push({ keyPath, delta });
    }
    return deltas;
  }

  /* -------------------------------------------- */

  /**
   * Prepare a delta for display in a chat message.
   * @this {IndividualDeltaData}
   * @param {Actor5e|Item5e} doc  Actor or item to which this delta applies.
   * @param {Roll[]} [rolls]      Rolls that may be associated with a delta.
   * @returns {DeltaDisplayContext}
   */
  static processDelta(doc, rolls=[]) {
    const type = doc instanceof Actor ? "actor" : "item";
    const value = this.keyPath.endsWith(".spent") ? -this.delta : this.delta;
    return {
      type,
      delta: formatNumber(value, { signDisplay: "always" }),
      document: doc,
      label: getHumanReadableAttributeLabel(this.keyPath, { [type]: doc }) ?? this.keyPath,
      rolls: rolls.map(roll => ({ roll, anchor: roll.toAnchor().outerHTML.replace(`${roll.total}</a>`, "</a>") }))
    };
  }
}

/**
 * Attempt to create a macro from the dropped data. Will use an existing macro if one exists.
 * @param {object} dropData     The dropped data
 * @param {number} slot         The hotbar slot to use
 * @returns {Promise}
 */
async function create5eMacro(dropData, slot) {
  const macroData = { type: "script", scope: "actor" };
  switch ( dropData.type ) {
    case "Activity":
      const activity = await fromUuid(dropData.uuid);
      if ( !activity ) {
        ui.notifications.warn("MACRO.5eUnownedWarn", { localize: true });
        return null;
      }
      foundry.utils.mergeObject(macroData, {
        name: `${activity.item.name}: ${activity.name}`,
        img: activity.img,
        command: `dnd5e.documents.macro.rollItem("${activity.item._source.name}", { activityName: "${
          activity._source.name}", event });`,
        flags: { "dnd5e.itemMacro": true }
      });
      break;
    case "Item":
      const itemData = await Item.implementation.fromDropData(dropData);
      if ( !itemData ) {
        ui.notifications.warn("MACRO.5eUnownedWarn", { localize: true });
        return null;
      }
      foundry.utils.mergeObject(macroData, {
        name: itemData.name,
        img: itemData.img,
        command: `dnd5e.documents.macro.rollItem("${itemData._source.name}", { event })`,
        flags: { "dnd5e.itemMacro": true }
      });
      break;
    case "ActiveEffect":
      const effectData = await ActiveEffect.implementation.fromDropData(dropData);
      if ( !effectData ) {
        ui.notifications.warn("MACRO.5eUnownedWarn", { localize: true });
        return null;
      }
      foundry.utils.mergeObject(macroData, {
        name: effectData.name,
        img: effectData.img,
        command: `dnd5e.documents.macro.toggleEffect("${effectData.name}")`,
        flags: { "dnd5e.effectMacro": true }
      });
      break;
    default:
      return;
  }

  // Assign the macro to the hotbar
  const macro = game.macros.find(m => {
    return (m.name === macroData.name) && (m.command === macroData.command) && m.isAuthor;
  }) || await Macro.create(macroData);
  game.user.assignHotbarMacro(macro, slot);
}

/* -------------------------------------------- */

/**
 * Find a document of the specified name and type on an assigned or selected actor.
 * @param {string} name          Document name to locate.
 * @param {string} documentType  Type of embedded document (e.g. "Item" or "ActiveEffect").
 * @returns {Document}           Document if found, otherwise nothing.
 */
function getMacroTarget(name, documentType) {
  let actor;
  const speaker = ChatMessage.getSpeaker();
  if ( speaker.token ) actor = game.actors.tokens[speaker.token];
  actor ??= game.actors.get(speaker.actor);
  if ( !actor ) {
    ui.notifications.warn("MACRO.5eNoActorSelected", {localize: true});
    return null;
  }

  const collection = (documentType === "Item") ? actor.items : Array.from(actor.allApplicableEffects());

  // Find item in collection
  const documents = collection.filter(i => i._source.name === name);
  const type = game.i18n.localize(`DOCUMENT.${documentType}`);
  if ( documents.length === 0 ) {
    ui.notifications.warn(game.i18n.format("MACRO.5eMissingTargetWarn", { actor: actor.name, type, name }));
    return null;
  }
  if ( documents.length > 1 ) {
    ui.notifications.warn(game.i18n.format("MACRO.5eMultipleTargetsWarn", { actor: actor.name, type, name }));
  }
  return documents[0];
}

/* -------------------------------------------- */

/**
 * Trigger an item to be used when a macro is clicked.
 * @param {string} itemName                Name of the item on the selected actor to trigger.
 * @param {object} [options={}]
 * @param {string} [options.activityName]  Name of a specific activity on the item to trigger.
 * @param {Event} [options.event]          The triggering event.
 * @returns {Promise<ChatMessage|object>}  Usage result.
 */
function rollItem(itemName, { activityName, event }={}) {
  let target = getMacroTarget(itemName, "Item");
  if ( activityName ) target = target?.system.activities?.getName(activityName);
  return target?.use({ event, legacy: false });
}

/* -------------------------------------------- */

/**
 * Toggle an effect on and off when a macro is clicked.
 * @param {string} effectName        Name of the effect to be toggled.
 * @returns {Promise<ActiveEffect>}  The effect after it has been toggled.
 */
function toggleEffect(effectName) {
  const effect = getMacroTarget(effectName, "ActiveEffect");
  return effect?.update({ disabled: !effect.disabled });
}

var macro = /*#__PURE__*/Object.freeze({
  __proto__: null,
  create5eMacro: create5eMacro,
  rollItem: rollItem,
  toggleEffect: toggleEffect
});

const slugify$1 = value => value?.slugify().replaceAll("-", "").replaceAll("(", "").replaceAll(")", "");

/**
 * Set up custom text enrichers.
 */
function registerCustomEnrichers() {
  const stringNames = [
    "attack", "award", "check", "concentration", "damage", "heal", "healing", "item", "save", "skill", "tool"
  ];
  CONFIG.TextEditor.enrichers.push({
    pattern: new RegExp(`\\[\\[/(?<type>${stringNames.join("|")})(?<config> .*?)?]](?!])(?:{(?<label>[^}]+)})?`, "gi"),
    enricher: enrichString
  },
  {
    pattern: /\[\[(?<type>lookup) (?<config>[^\]]+)]](?:{(?<label>[^}]+)})?/gi,
    enricher: enrichString
  },
  {
    pattern: /&(?<type>Reference)\[(?<config>[^\]]+)](?:{(?<label>[^}]+)})?/gi,
    enricher: enrichString
  });

  document.body.addEventListener("click", applyAction);
  document.body.addEventListener("click", awardAction);
  document.body.addEventListener("click", rollAction);
}

/* -------------------------------------------- */

/**
 * Parse the enriched string and provide the appropriate content.
 * @param {RegExpMatchArray} match       The regular expression match result.
 * @param {EnrichmentOptions} options    Options provided to customize text enrichment.
 * @returns {Promise<HTMLElement|null>}  An HTML element to insert in place of the matched text or null to
 *                                       indicate that no replacement should be made.
 */
async function enrichString(match, options) {
  let { type, config, label } = match.groups;
  config = parseConfig(config, { multiple: ["damage", "heal", "healing"].includes(type) });
  config._input = match[0];
  config._rules = _getRulesVersion(options);
  switch ( type.toLowerCase() ) {
    case "attack": return enrichAttack(config, label, options);
    case "award": return enrichAward(config, label);
    case "heal":
    case "healing": config._isHealing = true;
    case "damage": return enrichDamage(config, label, options);
    case "check":
    case "skill":
    case "tool": return enrichCheck(config, label, options);
    case "lookup": return enrichLookup(config, label, options);
    case "concentration": config._isConcentration = true;
    case "save": return enrichSave(config, label, options);
    case "item": return enrichItem(config, label, options);
    case "reference": return enrichReference(config, label);
  }
  return null;
}

/* -------------------------------------------- */

/**
 * Parse a roll string into a configuration object.
 * @param {string} match  Matched configuration string.
 * @param {object} [options={}]
 * @param {boolean} [options.multiple=false]  Support splitting configuration by "&" into multiple sub-configurations.
 *                                            If set to `true` then an array of configs will be returned.
 * @returns {object|object[]}
 */
function parseConfig(match="", { multiple=false }={}) {
  if ( multiple ) return match.split("&").map(s => parseConfig(s));
  const config = { _config: match, values: [] };
  for ( const part of match.match(/(?:[^\s"]+|"[^"]*")+/g) ?? [] ) {
    if ( !part ) continue;
    const [key, value] = part.split("=");
    const valueLower = value?.toLowerCase();
    if ( value === undefined ) config.values.push(key.replace(/(^"|"$)/g, ""));
    else if ( ["true", "false"].includes(valueLower) ) config[key] = valueLower === "true";
    else if ( Number.isNumeric(value) ) config[key] = Number(value);
    else config[key] = value.replace(/(^"|"$)/g, "");
  }
  return config;
}

/* -------------------------------------------- */

/**
 * Determine the appropriate rules version based on the provided document or system setting.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {string}
 */
function _getRulesVersion(options) {
  // Select from actor data first, then item data, and then fall back to system setting
  return options.relativeTo?.parent?.system?.source?.rules
    || options.relativeTo?.system?.source?.rules
    || (game.settings.get("dnd5e", "rulesVersion") === "modern" ? "2024" : "2014");
}

/* -------------------------------------------- */
/*  Attack Enricher                             */
/* -------------------------------------------- */

/**
 * Enrich an attack link using a pre-set to hit value.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the attack could be built, otherwise null.
 *
 * @example Create an attack link using a fixed to hit:
 * ```[[/attack +5]]``` or ```[[/attack formula=5]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="attack" data-formula="+5">
 *   <i class="fa-solid fa-dice-d20" inert></i> +5
 * </a>
 * ```
 *
 * @example Create an attack link using a specific attack mode:
 * ```[[/attack +5]]``` or ```[[/attack formula=5 attackMode=thrown]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="attack" data-formula="+5" data-attack-mode="thrown">
 *   <i class="fa-solid fa-dice-d20" inert></i> +5
 * </a>
 * ```
 *
 * @example Link an enricher to an attack activity, either explicitly or automatically:
 * ```[[/attack activity=RLQlsLo5InKHZadn]]``` or ```[[/attack]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="attack" data-formula="+8" data-activity-uuid="...uuid...">
 *   <i class="fa-solid fa-dice-d20" inert"></i> +8
 * </a>
 * ```
 *
 * @example Display the full attack section:
 * ```[[/attack format=extended]]``` or ```[[/attack extended]]```
 * becomes
 * ```html
 * <span class="attack-extended">
 *   <em>Melee Attack Roll</em>:
 *   <span class="roll-link-group" data-type="attack" data-formula="+16" data-activity-uuid="...uuid...">
 *     <a class="roll-link"><i class="fa-solid fa-dice-d20" inert"></i> +16</a>
 *   </span>, reach 15 ft
 * </span>
 * ```
 */
async function enrichAttack(config, label, options) {
  if ( config.activity && config.formula ) {
    console.warn(`Activity ID and formula found while enriching ${config._input}, only one is supported.`);
    return null;
  }

  const formulaParts = [];
  if ( config.formula ) formulaParts.push(config.formula);
  for ( const value of config.values ) {
    if ( value in CONFIG.DND5E.attackModes ) config.attackMode = value;
    else if ( value === "extended" ) config.format = "extended";
    else formulaParts.push(value);
  }
  config.formula = Roll.defaultImplementation.replaceFormulaData(formulaParts.join(" "), options.rollData ?? {});

  const activity = config.activity ? options.relativeTo?.system?.activities?.get(config.activity)
    : !config.formula ? options.relativeTo?.system?.activities?.getByType("attack")[0] : null;

  if ( activity ) {
    if ( activity.type !== "attack" ) {
      console.warn(`Attack enricher linked to non-attack activity when enriching ${config._input}`);
      return null;
    }

    config.activityUuid = activity.uuid;
    const attackConfig = activity.getAttackData({ attackMode: config.attackMode });
    config.formula = simplifyRollFormula(
      Roll.defaultImplementation.replaceFormulaData(attackConfig.parts.join(" + "), attackConfig.data)
    );
    delete config.activity;
  }

  if ( !config.activityUuid && !config.formula ) {
    console.warn(`No formula or linked activity found while enriching ${config._input}.`);
    return null;
  }

  config.type = "attack";
  if ( label ) return createRollLink(label, config, { classes: "roll-link-group roll-link" });

  let displayFormula = simplifyRollFormula(config.formula) || "+0";
  if ( !displayFormula.startsWith("+") && !displayFormula.startsWith("-") ) displayFormula = `+${displayFormula}`;

  const span = document.createElement("span");
  span.className = "roll-link-group";
  _addDataset(span, config);
  span.innerHTML = game.i18n.format(`EDITOR.DND5E.Inline.Attack${config._rules === "2014" ? "Long" : "Short"}`, {
    formula: createRollLink(displayFormula).outerHTML
  });

  if ( config.format === "extended" ) {
    const type = game.i18n.format(`DND5E.ATTACK.Formatted.${config._rules}`, {
      type: game.i18n.getListFormatter({ type: "disjunction" }).format(
        Array.from(activity?.validAttackTypes ?? []).map(t => CONFIG.DND5E.attackTypes[t]?.label)
      ),
      classification: CONFIG.DND5E.attackClassifications[activity?.attack.type.classification]?.label ?? ""
    }).trim();
    const parts = [span.outerHTML, activity?.getRangeLabel(config.attackMode)];
    if ( config._rules === "2014" ) parts.push(activity?.target?.affects.labels?.statblock);

    const full = document.createElement("span");
    full.className = "attack-extended";
    full.innerHTML = game.i18n.format("EDITOR.DND5E.Inline.AttackExtended", {
      type, parts: game.i18n.getListFormatter({ type: "unit" }).format(parts.filter(_ => _))
    });
    return full;
  }

  return span;
}

/* -------------------------------------------- */
/*  Award Enricher                              */
/* -------------------------------------------- */

/**
 * Enrich an award block displaying amounts for each part granted with a GM-control for awarding to the party.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the check could be built, otherwise null.
 */
async function enrichAward(config, label, options) {
  const command = config._config;
  let parsed;
  try {
    parsed = Award.parseAwardCommand(command);
  } catch(err) {
    console.warn(err.message);
    return null;
  }

  const block = document.createElement("span");
  block.classList.add("award-block", "dnd5e2");
  block.dataset.awardCommand = command;

  const entries = [];
  for ( let [key, amount] of Object.entries(parsed.currency) ) {
    const label = CONFIG.DND5E.currencies[key].label;
    amount = Number.isNumeric(amount) ? formatNumber(amount) : amount;
    entries.push(`
      <span class="award-entry">
        ${amount} <i class="currency ${key}" data-tooltip="${label}" aria-label="${label}"></i>
      </span>
    `);
  }
  if ( parsed.xp ) entries.push(`
    <span class="award-entry">
      ${formatNumber(parsed.xp)} ${game.i18n.localize("DND5E.ExperiencePoints.Abbreviation")}
    </span>
  `);

  let award = game.i18n.getListFormatter({ type: "unit" }).format(entries);
  if ( parsed.each ) award = game.i18n.format("EDITOR.DND5E.Inline.AwardEach", { award });

  block.innerHTML += `
    ${award}
    <a class="award-link" data-action="awardRequest">
      <i class="fa-solid fa-trophy"></i> ${label ?? game.i18n.localize("DND5E.Award.Action")}
    </a>
  `;

  return block;
}

/* -------------------------------------------- */
/*  Check & Save Enrichers                      */
/* -------------------------------------------- */

/**
 * Enrich an ability check link to perform a specific ability or skill check. If an ability is provided
 * along with a skill, then the skill check will always use the provided ability. Otherwise it will use
 * the character's default ability for that skill.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the check could be built, otherwise null.
 *
 * @example Create a dexterity check:
 * ```[[/check ability=dex]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="dex">
 *   <i class="fa-solid fa-dice-d20" inert></i> Dexterity check
 * </a>
 * ```
 *
 * @example Create an acrobatics check with a DC and default ability:
 * ```[[/check skill=acr dc=20]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-skill="acr" data-dc="20">
 *   <i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity (Acrobatics) check
 * </a>
 * ```
 *
 * @example Create an acrobatics check using strength:
 * ```[[/check ability=str skill=acr]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="str" data-skill="acr">
 *   <i class="fa-solid fa-dice-d20" inert></i> Strength (Acrobatics) check
 * </a>
 * ```
 *
 * @example Create a tool check:
 * ```[[/check tool=thief ability=int]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="int" data-tool="thief">
 *   <i class="fa-solid fa-dice-d20" inert></i> Intelligence (Thieves' Tools) check
 * </a>
 * ```
 *
 * @example Formulas used for DCs will be resolved using data provided to the description (not the roller):
 * ```[[/check ability=cha dc=@abilities.int.dc]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="check" data-ability="cha" data-dc="15">
 *   <i class="fa-solid fa-dice-d20" inert></i> DC 15 Charisma check
 * </a>
 * ```
 *
 * @example Use multiple skills in a check using default abilities:
 * ```[[/check skill=acr/ath dc=15]]```
 * ```[[/check acrobatics athletics 15]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="check" data-skill="acr|ath" data-dc="15">
 *   DC 15
 *   <a class="roll-action" data-ability="dex" data-skill="acr">
 *     <i class="fa-solid fa-dice-d20" inert></i> Dexterity (Acrobatics)
 *   </a> or
 *   <a class="roll-action" data-ability="dex">
 *     <i class="fa-solid fa-dice-d20" inert></i> Strength (Athletics)
 *   </a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Use multiple skills with a fixed ability:
 * ```[[/check ability=str skill=dec/per dc=15]]```
 * ```[[/check strength deception persuasion 15]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="check" data-ability="str" data-skill="dec|per" data-dc="15">
 *   DC 15 Strength
 *   (<a class="roll-action" data-skill="dec"><i class="fa-solid fa-dice-d20" inert></i> Deception</a> or
 *   <a class="roll-action" data-ability="per"><i class="fa-solid fa-dice-d20" inert></i> Persuasion</a>)
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Link an enricher to an check activity, either explicitly or automatically
 * ```[[/check activity=RLQlsLo5InKHZadn]]``` or ```[[/check]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="check" data-ability="dex" data-dc="20" data-activity-uuid="...">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 */
async function enrichCheck(config, label, options) {
  config.skill = config.skill?.replaceAll("/", "|").split("|") ?? [];
  config.tool = config.tool?.replaceAll("/", "|").split("|") ?? [];
  for ( let value of config.values ) {
    const slug = foundry.utils.getType(value) === "string" ? slugify$1(value) : value;
    if ( slug in CONFIG.DND5E.enrichmentLookup.abilities ) config.ability = slug;
    else if ( slug in CONFIG.DND5E.enrichmentLookup.skills ) config.skill.push(slug);
    else if ( slug in CONFIG.DND5E.enrichmentLookup.tools ) config.tool.push(slug);
    else if ( Number.isNumeric(value) ) config.dc = Number(value);
    else config[value] = true;
  }
  delete config.values;

  const groups = new Map();
  let invalid = false;

  const anything = config.ability || config.skill.length || config.tool.length;
  const activity = config.activity ? options.relativeTo?.system?.activities?.get(config.activity)
    : !anything ? options.relativeTo?.system?.activities?.getByType("check")[0] : null;

  if ( activity ) {
    if ( activity.type !== "check" ) {
      console.warn(`Check enricher linked to non-check activity when enriching ${config._input}.`);
      return null;
    }

    if ( activity.check.ability ) config.ability = activity.check.ability;
    config.activityUuid = activity.uuid;
    config.dc = activity.check.dc.value;
    config.skill = [];
    config.tool = [];
    for ( const associated of activity.check.associated ) {
      if ( associated in CONFIG.DND5E.skills ) config.skill.push(associated);
      else if ( associated in CONFIG.DND5E.tools ) config.tool.push(associated);
    }
    delete config.activity;
  }

  // TODO: Support "spellcasting" ability
  let abilityConfig = CONFIG.DND5E.enrichmentLookup.abilities[slugify$1(config.ability)];
  if ( config.ability && !abilityConfig ) {
    console.warn(`Ability "${config.ability}" not found while enriching ${config._input}.`);
    invalid = true;
  } else if ( abilityConfig?.key ) config.ability = abilityConfig.key;

  for ( let [index, skill] of config.skill.entries() ) {
    const skillConfig = CONFIG.DND5E.enrichmentLookup.skills[slugify$1(skill)];
    if ( skillConfig ) {
      if ( skillConfig.key ) skill = config.skill[index] = skillConfig.key;
      const ability = config.ability || skillConfig.ability;
      if ( !groups.has(ability) ) groups.set(ability, []);
      groups.get(ability).push({ key: skill, type: "skill", label: skillConfig.label });
    } else {
      console.warn(`Skill "${skill}" not found while enriching ${config._input}.`);
      invalid = true;
    }
  }

  for ( const tool of config.tool ) {
    const toolConfig = CONFIG.DND5E.tools[slugify$1(tool)];
    const toolUUID = CONFIG.DND5E.enrichmentLookup.tools[slugify$1(tool)];
    const toolIndex = toolUUID ? getBaseItem(toolUUID, { indexOnly: true }) : null;
    if ( toolIndex ) {
      const ability = config.ability || toolConfig?.ability;
      if ( ability ) {
        if ( !groups.has(ability) ) groups.set(ability, []);
        groups.get(ability).push({ key: tool, type: "tool", label: toolIndex.name });
      } else {
        console.warn(`Tool "${tool}" found without specified or default ability while enriching ${config._input}.`);
        invalid = true;
      }
    } else {
      console.warn(`Tool "${tool}" not found while enriching ${config._input}.`);
      invalid = true;
    }
  }

  if ( !abilityConfig && !groups.size ) {
    console.warn(`No ability, skill, tool, or linked activity provided while enriching ${config._input}.`);
    invalid = true;
  }

  const complex = (config.skill.length + config.tool.length) > 1;
  if ( config.passive && complex ) {
    console.warn(`Multiple skills or tools and passive flag found while enriching ${config._input}, which aren't supported together.`);
    invalid = true;
  }
  if ( label && complex ) {
    console.warn(`Multiple skills or tools and a custom label found while enriching ${config._input}, which aren't supported together.`);
    invalid = true;
  }

  if ( config.dc && !Number.isNumeric(config.dc) ) config.dc = simplifyBonus(config.dc, options.rollData);

  if ( invalid ) return null;

  if ( complex ) {
    const formatter = game.i18n.getListFormatter({ type: "disjunction" });
    const parts = [];
    for ( const [ability, associated] of groups.entries() ) {
      const makeConfig = ({ key, type }) => ({ type, [type]: key, ability: groups.size > 1 ? ability : undefined });

      // Multiple associated proficiencies, link each individually
      if ( associated.length > 1 ) parts.push(
        game.i18n.format("EDITOR.DND5E.Inline.SpecificCheck", {
          ability: CONFIG.DND5E.enrichmentLookup.abilities[ability].label,
          type: formatter.format(associated.map(a => createRollLink(a.label, makeConfig(a)).outerHTML ))
        })
      );

      // Only single associated proficiency, wrap whole thing in roll link
      else {
        const associatedConfig = makeConfig(associated[0]);
        parts.push(createRollLink(createRollLabel({ ...associatedConfig, ability }), associatedConfig).outerHTML);
      }
    }
    label = formatter.format(parts);
    if ( config.dc && !config.hideDC ) {
      label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
    }
    label = game.i18n.format(`EDITOR.DND5E.Inline.Check${config.format === "long" ? "Long" : "Short"}`, { check: label });
    const template = document.createElement("template");
    template.innerHTML = label;
    return createRequestLink(template, {
      type: "check", ...config, skill: config.skill.join("|"), tool: config.tool.join("|")
    });
  }

  const type = config.skill.length ? "skill" : config.tool.length ? "tool" : "check";
  config = { type, ability: Array.from(groups.keys())[0], ...config, skill: config.skill[0], tool: config.tool[0] };
  if ( !label ) label = createRollLabel(config);
  return config.passive ? createPassiveTag(label, config) : createRequestLink(createRollLink(label), config);
}

/* -------------------------------------------- */

/**
 * Create the buttons for a check requested in chat.
 * @param {object} dataset
 * @returns {object[]}
 */
function createCheckRequestButtons(dataset) {
  const skills = dataset.skill?.split("|") ?? [];
  const tools = dataset.tool?.split("|") ?? [];
  if ( (skills.length + tools.length) <= 1 ) return [createRequestButton(dataset)];
  const baseDataset = { ...dataset };
  delete baseDataset.skill;
  delete baseDataset.tool;
  return [
    ...skills.map(skill => createRequestButton({
      ability: CONFIG.DND5E.skills[skill].ability, ...baseDataset, format: "short", skill, type: "skill"
    })),
    ...tools.map(tool => createRequestButton({
      ability: CONFIG.DND5E.tools[tool]?.ability, ...baseDataset, format: "short", tool, type: "tool"
    }))
  ];
}

/* -------------------------------------------- */

/**
 * Enrich a saving throw link.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the save could be built, otherwise null.
 *
 * @example Create a dexterity saving throw:
 * ```[[/save ability=dex]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="dex">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Add a DC to the save:
 * ```[[/save ability=dex dc=20]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="dex" data-dc="20">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Specify multiple abilities:
 * ```[[/save ability=str/dex dc=20]]```
 * ```[[/save strength dexterity 20]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="str|dex" data-dc="20">
 *   DC 20
 *   <a class="roll-action" data-ability="str"><i class="fa-solid fa-dice-d20" inert></i> Strength</a> or
 *   <a class="roll-action" data-ability="dex"><i class="fa-solid fa-dice-d20" inert></i> Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Create a concentration saving throw:
 * ```[[/concentration 10]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="concentration" data-dc=10>
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 10 concentration</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 *
 * @example Link an enricher to an save activity, either explicitly or automatically
 * ```[[/save activity=RLQlsLo5InKHZadn]]``` or ```[[/save]]```
 * becomes
 * ```html
 * <span class="roll-link-group" data-type="save" data-ability="dex" data-dc="20" data-activity-uuid="...">
 *   <a class="roll-action"><i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity</a>
 *   <a class="enricher-action" data-action="request" ...><!-- request link --></a>
 * </span>
 * ```
 */
async function enrichSave(config, label, options) {
  config.ability = config.ability?.replace("/", "|").split("|") ?? [];
  for ( let value of config.values ) {
    const slug = foundry.utils.getType(value) === "string" ? slugify$1(value) : value;
    if ( slug in CONFIG.DND5E.enrichmentLookup.abilities ) config.ability.push(slug);
    else if ( Number.isNumeric(value) ) config.dc = Number(value);
    else config[value] = true;
  }
  config.ability = config.ability
    .filter(a => a in CONFIG.DND5E.enrichmentLookup.abilities)
    .map(a => CONFIG.DND5E.enrichmentLookup.abilities[a].key ?? a);

  const activity = config.activity ? options.relativeTo?.system?.activities?.get(config.activity)
    : !config.ability.length ? options.relativeTo?.system?.activities?.getByType("save")[0] : null;

  if ( activity ) {
    if ( activity.type !== "save" ) {
      console.warn(`Save enricher linked to non-save activity when enriching ${config._input}`);
      return null;
    }

    config.ability = Array.from(activity.save.ability);
    config.activityUuid = activity.uuid;
    config.dc = activity.save.dc.value;
    delete config.activity;
  }

  if ( !config.ability.length && !config._isConcentration ) {
    console.warn(`No ability or linked activity found while enriching ${config._input}.`);
    return null;
  }

  if ( config.dc && !Number.isNumeric(config.dc) ) config.dc = simplifyBonus(config.dc, options.rollData);

  if ( config.ability.length > 1 && label ) {
    console.warn(`Multiple abilities and custom label found while enriching ${config._input}, which aren't supported together.`);
    return null;
  }

  config = { type: config._isConcentration ? "concentration" : "save", ...config };
  if ( label ) label = createRollLink(label);
  else if ( config.ability.length <= 1 ) label = createRollLink(createRollLabel(config));
  else {
    label = game.i18n.getListFormatter({ type: "disjunction" }).format(config.ability.map(ability =>
      createRollLink(createRollLabel({ type: "save", ability }), { ability }).outerHTML
    ));
    if ( config.dc && !config.hideDC ) {
      label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
    }
    label = game.i18n.format(`EDITOR.DND5E.Inline.Save${config.format === "long" ? "Long" : "Short"}`, { save: label });
    const template = document.createElement("template");
    template.innerHTML = label;
    label = template;
  }
  return createRequestLink(label, { ...config, ability: config.ability.join("|") });
}

/* -------------------------------------------- */

/**
 * Create the buttons for a save requested in chat.
 * @param {object} dataset
 * @returns {object[]}
 */
function createSaveRequestButtons(dataset) {
  return (dataset.ability?.split("|") ?? [])
    .map(ability => createRequestButton({ ...dataset, format: "long", ability }));
}

/* -------------------------------------------- */
/*  Damage Enricher                             */
/* -------------------------------------------- */

/**
 * Enrich a damage link.
 * @param {object[]} configs           Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link if the save could be built, otherwise null.
 *
 * @example Create a damage link:
 * ```[[/damage 2d6 type=bludgeoning]]``
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="bludgeoning">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span> bludgeoning
 * </a>
 * ````
 *
 * @example Display the average:
 * ```[[/damage 2d6 type=bludgeoning average=true]]``
 * becomes
 * ```html
 * 7 (<a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="bludgeoning">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span>
 * </a>) bludgeoning
 * ````
 *
 * @example Manually set the average & don't prefix the type:
 * ```[[/damage 8d4dl force average=666]]``
 * becomes
 * ```html
 * 666 (<a class="roll-link-group" data-type="damage" data-formulas="8d4dl" data-damage-types="force">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 8d4dl</span>
 * </a> force
 * ````
 *
 * @example Create a healing link:
 * ```[[/heal 2d6]]``` or ```[[/damage 2d6 healing]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="healing">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span>
 * </a> healing
 * ```
 *
 * @example Specify variable damage types:
 * ```[[/damage 2d6 type=fire|cold]]``` or ```[[/damage 2d6 type=fire/cold]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="fire|cold">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span>
 * </a> fire or cold
 * ```
 *
 * @example Add multiple damage parts
 * ```[[/damage 1d6 fire & 1d6 cold]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="1d6&1d6" data-damage-types="fire&cold">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> fire and
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> cold
 * </a>
 * ```
 *
 * @example Link an enricher to an damage activity, either explicitly or automatically
 * ```[[/damage activity=RLQlsLo5InKHZadn]]``` or ```[[/damage]]```
 * becomes
 * ```html
 * <a class="roll-link-group" data-type="damage" data-formulas="1d6&1d6" data-damage-types="fire&cold"
 *    data-activity-uuid="...">
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> fire and
 *   <span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 1d6</span> cold
 * </a>
 * ```
 *
 * @example Displaying the full hit section:
 * ```[[/damage extended]]``
 * becomes
 * ```html
 * <span class="damage-extended">
 *   <em>Hit:</em>
 *   <a class="roll-link-group" data-type="damage" data-formulas="2d6" data-damage-types="bludgeoning"
 *      data-activity-uuid="...">
 *     7 (<span class="roll-link"><i class="fa-solid fa-dice-d20"></i> 2d6</span></a>) Bludgeoning damage
 *   </a>
 * </span>
 * ````
 */
async function enrichDamage(configs, label, options) {
  const config = { type: "damage", formulas: [], damageTypes: [], rollType: configs._isHealing ? "healing" : "damage" };
  for ( const c of configs ) {
    const formulaParts = [];
    if ( c.activity ) config.activity = c.activity;
    if ( c.attackMode ) config.attackMode = c.attackMode;
    if ( c.average ) config.average = c.average;
    if ( c.format ) config.format = c.format;
    if ( c.formula ) formulaParts.push(c.formula);
    c.type = c.type?.replaceAll("/", "|").split("|") ?? [];
    for ( const value of c.values ) {
      if ( value in CONFIG.DND5E.damageTypes ) c.type.push(value);
      else if ( value in CONFIG.DND5E.healingTypes ) c.type.push(value);
      else if ( value in CONFIG.DND5E.attackModes ) config.attackMode = value;
      else if ( value === "average" ) config.average = true;
      else if ( value === "extended" ) config.format = "extended";
      else if ( value === "temp" ) c.type.push("temphp");
      else formulaParts.push(value);
    }
    c.formula = Roll.defaultImplementation.replaceFormulaData(formulaParts.join(" "), options.rollData ?? {});
    if ( configs._isHealing && !c.type.length ) c.type.push("healing");
    if ( c.formula ) {
      config.formulas.push(c.formula);
      config.damageTypes.push(c.type.join("|"));
    }
  }
  config.damageTypes = config.damageTypes.map(t => t?.replace("/", "|"));
  if ( config.format === "extended" ) config.average ??= true;

  if ( config.activity && config.formulas.length ) {
    console.warn(`Activity ID and formulas found while enriching ${config._input}, only one is supported.`);
    return null;
  }

  let activity = options.relativeTo?.system?.activities?.get(config.activity);
  if ( !activity && !config.formulas.length ) {
    const types = configs._isHealing ? ["heal"] : ["attack", "damage", "save"];
    for ( const a of options.relativeTo?.system?.activities?.getByTypes(...types) ?? [] ) {
      if ( a.damage?.parts.length || a.healing?.formula ) {
        activity = a;
        break;
      }
    }
  }

  if ( activity ) {
    config.activityUuid = activity.uuid;
    const damageConfig = activity.getDamageConfig({ attackMode: config.attackMode });
    for ( const roll of damageConfig.rolls ) {
      config.formulas.push(simplifyRollFormula(
        Roll.defaultImplementation.replaceFormulaData(roll.parts.join(" + "), roll.data)
      ));
      config.damageTypes.push(roll.options.types?.join("|") ?? roll.options.type);
    }
    delete config.activity;
  }

  if ( !config.activityUuid && !config.formulas.length ) {
    console.warn(`No formula or linked activity found while enriching ${config._input}.`);
    return null;
  }

  const formulas = config.formulas.join("&");
  const damageTypes = config.damageTypes.join("&");

  if ( !config.formulas.length ) return null;
  if ( label ) {
    return createRollLink(label, { ...config, formulas, damageTypes }, { classes: "roll-link-group roll-link" });
  }

  const parts = [];
  for ( const [idx, formula] of config.formulas.entries() ) {
    const type = config.damageTypes[idx];
    const types = type?.split("|")
      .map(t => CONFIG.DND5E.damageTypes[t]?.label ?? CONFIG.DND5E.healingTypes[t]?.label)
      .filter(_ => _);
    const localizationData = {
      formula: createRollLink(formula, {}, { tag: "span" }).outerHTML,
      type: game.i18n.getListFormatter({ type: "disjunction" }).format(types)
    };
    if ( configs._rules === "2014" ) localizationData.type = localizationData.type.toLowerCase();

    let localizationType = "Short";
    if ( config.average ) {
      localizationType = "Long";
      if ( config.average === true ) {
        const minRoll = Roll.create(formula).evaluate({ minimize: true });
        const maxRoll = Roll.create(formula).evaluate({ maximize: true });
        localizationData.average = Math.floor(((await minRoll).total + (await maxRoll).total) / 2);
      } else if ( Number.isNumeric(config.average) ) {
        localizationData.average = config.average;
      } else {
        localizationType = "Short";
      }
    }

    parts.push(game.i18n.format(`EDITOR.DND5E.Inline.Damage${localizationType}`, localizationData));
  }

  const link = document.createElement("a");
  link.className = "roll-link-group";
  _addDataset(link, { ...config, formulas, damageTypes });
  if ( config.average && (parts.length === 2) ) {
    link.innerHTML = game.i18n.format("EDITOR.DND5E.Inline.DamageDouble", { first: parts[0], second: parts[1] });
  } else {
    link.innerHTML = game.i18n.getListFormatter().format(parts);
  }

  if ( config.format === "extended" ) {
    const span = document.createElement("span");
    span.className = "damage-extended";
    span.innerHTML = game.i18n.format("EDITOR.DND5E.Inline.DamageExtended", { damage: link.outerHTML });
    return span;
  }

  return link;
}

/* -------------------------------------------- */
/*  Lookup Enricher                             */
/* -------------------------------------------- */

/**
 * Enrich a property lookup.
 * @param {object} config              Configuration data.
 * @param {string} [fallback]          Optional fallback if the value couldn't be found.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML element if the lookup could be built, otherwise null.
 *
 * @example Include a creature's name in its description:
 * ```[[lookup @name]]```
 * becomes
 * ```html
 * <span class="lookup-value">Adult Black Dragon</span>
 * ```
 *
 * @example Lookup a property within an activity:
 * ```[[lookup @target.template.size activity=dnd5eactivity000]]```
 * becomes
 * ```html
 * <span class="lookup-value">120</span>
 * ```
 */
function enrichLookup(config, fallback, options) {
  let keyPath = config.path;
  let style = config.style;
  for ( const value of config.values ) {
    if ( value === "capitalize" ) style ??= "capitalize";
    else if ( value === "lowercase" ) style ??= "lowercase";
    else if ( value === "uppercase" ) style ??= "uppercase";
    else if ( value.startsWith("@") ) keyPath ??= value;
  }

  let activity = options.relativeTo?.system?.activities?.get(config.activity);
  if ( config.activity && !activity ) {
    console.warn(`Activity not found when enriching ${config._input}.`);
    return null;
  }

  if ( !keyPath ) {
    console.warn(`Lookup path must be defined to enrich ${config._input}.`);
    return null;
  }

  const data = activity ? activity.getRollData().activity : options.rollData
    ?? options.relativeTo?.getRollData?.() ?? {};
  let value = foundry.utils.getProperty(data, keyPath.substring(1)) ?? fallback;
  if ( value && style ) {
    if ( style === "capitalize" ) value = value.capitalize();
    else if ( style === "lowercase" ) value = value.toLowerCase();
    else if ( style === "uppercase" ) value = value.toUpperCase();
  }

  const span = document.createElement("span");
  span.classList.add("lookup-value");
  if ( !value && (options.documents === false) ) return null;
  if ( !value ) span.classList.add("not-found");
  span.innerText = value ?? keyPath;
  return span;
}

/* -------------------------------------------- */
/*  Reference Enricher                          */
/* -------------------------------------------- */

/**
 * Enrich a reference link.
 * @param {object} config              Configuration data.
 * @param {string} [label]             Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {HTMLElement|null}         An HTML link to the Journal Entry Page for the given reference.
 *
 * @example Create a content link to the relevant reference:
 * ```&Reference[condition=unconscious]{Label}```
 * becomes
 * ```html
 * <span class="reference-link">
 *   <a class="content-link" draggable="true"
 *      data-uuid="Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.UWw13ISmMxDzmwbd"
 *      data-type="JournalEntryPage" data-tooltip="Text Page">
 *     <i class="fas fa-book-open"></i> Label
 *   </a>
 *   <a class="enricher-action" data-action="apply" data-status="unconscious"
 *      data-tooltip="EDITOR.DND5E.Inline.ApplyStatus" aria-label="Apply Status to Selected Tokens">
 *     <i class="fas fa-fw fa-reply-all fa-flip-horizontal"></i>
 *   </a>
 * </span>
 * ```
 */
async function enrichReference(config, label, options) {
  let key;
  let source;
  let isCondition = "condition" in config;
  const type = Object.keys(config).find(k => k in CONFIG.DND5E.ruleTypes);
  if ( type ) {
    key = slugify$1(config[type]);
    source = foundry.utils.getProperty(CONFIG.DND5E, CONFIG.DND5E.ruleTypes[type].references)?.[key];
  } else if ( config.values.length ) {
    key = slugify$1(config.values.join(""));
    for ( const [type, { references }] of Object.entries(CONFIG.DND5E.ruleTypes) ) {
      source = foundry.utils.getProperty(CONFIG.DND5E, references)[key];
      if ( source ) {
        if ( type === "condition" ) isCondition = true;
        break;
      }
    }
  }
  if ( !source ) {
    console.warn(`No valid rule found while enriching ${config._input}.`);
    return null;
  }
  const uuid = foundry.utils.getType(source) === "Object" ? source.reference : source;
  if ( !uuid ) return null;
  const doc = await fromUuid(uuid);
  const span = document.createElement("span");
  span.classList.add("reference-link");
  span.append(doc.toAnchor({ name: label || doc.name }));
  if ( isCondition && (config.apply !== false) ) {
    const apply = document.createElement("a");
    apply.classList.add("enricher-action");
    apply.dataset.action = "apply";
    apply.dataset.status = key;
    apply.dataset.tooltip = "EDITOR.DND5E.Inline.ApplyStatus";
    apply.setAttribute("aria-label", game.i18n.localize(apply.dataset.tooltip));
    apply.innerHTML = '<i class="fas fa-fw fa-reply-all fa-flip-horizontal"></i>';
    span.append(apply);
  }
  return span;
}

/* -------------------------------------------- */
/*  Helpers                                     */
/* -------------------------------------------- */

/**
 * Enrich an item use link to roll an item on the selected token.
 * @param {string[]} config              Configuration data.
 * @param {string} [label]               Optional label to replace default text.
 * @param {EnrichmentOptions} options  Options provided to customize text enrichment.
 * @returns {Promise<HTMLElement|null>}  An HTML link if the item link could be built, otherwise null.
 *
 * @example Use an Item from a name:
 * ```[[/item Heavy Crossbow]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-name="Heavy Crossbow">
 *   <i class="fa-solid fa-dice-d20"></i> Heavy Crossbow
 * </a>
 * ```
 *
 * @example Use an Item from a UUID:
 * ```[[/item Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-uuid="Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU">
 *   <i class="fa-solid fa-dice-d20"></i> Bite
 * </a>
 * ```
 *
 * @example Use an Item from an ID:
 * ```[[/item amUUCouL69OK1GZU]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-uuid="Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU">
 *   <i class="fa-solid fa-dice-d20"></i> Bite
 * </a>
 * ```
 *
 * @example Use an Activity on an Item from a name:
 * ```[[/item Heavy Crossbow activity=Poison]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item" data-roll-item-name="Heavy Crossbow" data-roll-activity-name="Poison">
 *   <i class="fa-solid fa-dice-d20"></i> Heavy Crossbow: Poison
 * </a>
 * ```
 *
 * @example Use an Activity on an Item:
 * ```[[/item amUUCouL69OK1GZU activity=G8ng63Tjqy5W52OP]]```
 * becomes
 * ```html
 * <a class="roll-action" data-type="item"
 *    data-roll-activity-uuid="Actor.M4eX4Mu5IHCr3TMf.Item.amUUCouL69OK1GZU.Activity.G8ng63Tjqy5W52OP">
 *   <i class="fa-solid fa-dice-d20"></i> Bite: Save
 * </a>
 * ```
 */
async function enrichItem(config, label, options) {
  const givenItem = config.values.join(" ");
  // If config is a UUID
  const itemUuidMatch = givenItem.match(
    /^(?<synthid>Scene\.\w{16}\.Token\.\w{16}\.)?(?<actorid>Actor\.\w{16})(?<itemid>\.?Item(?<relativeId>\.\w{16}))$/
  );

  const makeLink = (label, dataset) => {
    const span = document.createElement("span");
    span.classList.add("roll-link-group");
    _addDataset(span, dataset);
    span.append(createRollLink(label));
    return span;
  };

  if ( itemUuidMatch ) {
    const ownerActor = itemUuidMatch.groups.actorid.trim();
    if ( !label ) {
      const item = await fromUuid(givenItem);
      if ( !item ) {
        console.warn(`Item not found while enriching ${config._input}.`);
        return null;
      }
      label = item.name;
    }
    return makeLink(label, { type: "item", rollItemActor: ownerActor, rollItemUuid: givenItem });
  }

  let foundItem;
  const foundActor = options.relativeTo instanceof Item
    ? options.relativeTo.parent
    : options.relativeTo instanceof Actor ? options.relativeTo : null;

  // If config is an Item ID
  if ( /^\w{16}$/.test(givenItem) && foundActor ) foundItem = foundActor.items.get(givenItem);

  // If config is a relative UUID
  if ( givenItem.startsWith(".") ) {
    try {
      foundItem = await fromUuid(givenItem, { relative: options.relativeTo });
    } catch(err) { return null; }
  }

  if ( foundItem ) {
    let foundActivity;
    if ( config.activity ) {
      foundActivity = foundItem.system.activities?.get(config.activity)
        ?? foundItem.system.activities?.getName(config.activity);
      if ( !foundActivity ) {
        console.warn(`Activity ${config.activity} not found on ${foundItem.name} while enriching ${config._input}.`);
        return null;
      }
      if ( !label ) label = `${foundItem.name}: ${foundActivity.name}`;
      return makeLink(label, { type: "item", rollActivityUuid: foundActivity.uuid });
    }

    if ( !label ) label = foundItem.name;
    return makeLink(label, { type: "item", rollItemUuid: foundItem.uuid });
  }

  // Finally, if config is an item name
  if ( !label ) label = config.activity ? `${givenItem}: ${config.activity}` : givenItem;
  return makeLink(label, {
    type: "item", rollItemActor: foundActor?.uuid, rollItemName: givenItem, rollActivityName: config.activity
  });
}

/* -------------------------------------------- */

/**
 * Add a dataset object to the provided element.
 * @param {HTMLElement} element  Element to modify.
 * @param {object} dataset       Data properties to add.
 * @private
 */
function _addDataset(element, dataset) {
  for ( const [key, value] of Object.entries(dataset) ) {
    if ( !key.startsWith("_") && (key !== "values") && value ) element.dataset[key] = value;
  }
}

/* -------------------------------------------- */

/**
 * Create a passive skill tag.
 * @param {string} label    Label to display.
 * @param {object} dataset  Data that will be added to the tag.
 * @returns {HTMLElement}
 */
function createPassiveTag(label, dataset) {
  const span = document.createElement("span");
  span.classList.add("passive-check");
  _addDataset(span, {
    ...dataset,
    tooltip: `
      <section class="loading" data-passive><i class="fas fa-spinner fa-spin-pulse"></i></section>
    `
  });
  span.innerText = label;
  return span;
}

/* -------------------------------------------- */

/**
 * Create a label for a roll message.
 * @param {object} config  Configuration data.
 * @returns {string}
 */
function createRollLabel(config) {
  const { label: ability, abbreviation } = CONFIG.DND5E.abilities[config.ability] ?? {};
  const skill = CONFIG.DND5E.skills[config.skill]?.label;
  const toolUUID = CONFIG.DND5E.enrichmentLookup.tools[config.tool];
  const tool = toolUUID ? getBaseItem(toolUUID, { indexOnly: true })?.name : null;
  const longSuffix = config.format === "long" ? "Long" : "Short";
  const showDC = config.dc && !config.hideDC;

  let label;
  switch ( config.type ) {
    case "check":
    case "skill":
    case "tool":
      if ( ability && (skill || tool) ) {
        label = game.i18n.format("EDITOR.DND5E.Inline.SpecificCheck", { ability, type: skill ?? tool });
      } else {
        label = ability;
      }
      if ( config.passive ) {
        label = game.i18n.format(
          `EDITOR.DND5E.Inline.${showDC ? "DC" : ""}Passive${longSuffix}`, { dc: config.dc, check: label }
        );
      } else {
        if ( showDC ) label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
        label = game.i18n.format(`EDITOR.DND5E.Inline.Check${longSuffix}`, { check: label });
      }
      break;
    case "concentration":
    case "save":
      if ( config.type === "save" ) label = ability;
      else label = `${game.i18n.localize("DND5E.Concentration")} ${ability ? `(${abbreviation})` : ""}`;
      if ( showDC ) label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
      label = game.i18n.format(`EDITOR.DND5E.Inline.Save${longSuffix}`, { save: label });
      break;
    default:
      return "";
  }

  if ( config.icon ) {
    switch ( config.type ) {
      case "check":
      case "skill":
        label = `<i class="dnd5e-icon" data-src="systems/dnd5e/icons/svg/ability-score-improvement.svg"></i>${label}`;
        break;
      case "tool":
        label = `<i class="fas fa-hammer"></i>${label}`;
        break;
      case "concentration":
      case "save":
        label = `<i class="fas fa-shield-heart"></i>${label}`;
        break;
    }
  }

  return label;
}

/* -------------------------------------------- */

/**
 * Create a rollable link with a request section for GMs.
 * @param {HTMLElement|string} label  Label to display
 * @param {object} dataset            Data that will be added to the link for the rolling method.
 * @returns {HTMLElement}
 */
function createRequestLink(label, dataset) {
  const span = document.createElement("span");
  span.classList.add("roll-link-group");
  _addDataset(span, dataset);
  if ( label instanceof HTMLTemplateElement ) span.append(label.content);
  else span.append(label);

  // Add chat request link for GMs
  if ( game.user.isGM ) {
    const gmLink = document.createElement("a");
    gmLink.classList.add("enricher-action");
    gmLink.dataset.action = "request";
    gmLink.dataset.tooltip = "EDITOR.DND5E.Inline.RequestRoll";
    gmLink.setAttribute("aria-label", game.i18n.localize(gmLink.dataset.tooltip));
    gmLink.insertAdjacentHTML("afterbegin", '<i class="fa-solid fa-comment-dots"></i>');
    span.insertAdjacentElement("beforeend", gmLink);
  }

  return span;
}

/* -------------------------------------------- */

/**
 * Create a rollable link.
 * @param {string} label                           Label to display.
 * @param {object} [dataset={}]                    Data that will be added to the link for the rolling method.
 * @param {object} [options={}]
 * @param {boolean} [options.classes="roll-link"]  Class to add to the link.
 * @param {string} [options.tag="a"]               Tag to use for the main link.
 * @returns {HTMLElement}
 */
function createRollLink(label, dataset={}, { classes="roll-link", tag="a" }={}) {
  const link = document.createElement(tag);
  link.className = classes;
  link.insertAdjacentHTML("afterbegin", '<i class="fa-solid fa-dice-d20" inert></i>');
  link.append(label);
  _addDataset(link, dataset);
  return link;
}

/* -------------------------------------------- */
/*  Actions                                     */
/* -------------------------------------------- */

/**
 * Toggle status effects on selected tokens.
 * @param {PointerEvent} event  The triggering event.
 * @returns {Promise<void>}
 */
async function applyAction(event) {
  const target = event.target.closest('[data-action="apply"][data-status]');
  const status = target?.dataset.status;
  if ( !status ) return;
  event.stopPropagation();
  const actors = new Set();
  for ( const { actor } of canvas.tokens.controlled ) {
    if ( !actor || actors.has(actor) ) continue;
    await actor.toggleStatusEffect(status);
    actors.add(actor);
  }
}

/* -------------------------------------------- */

/**
 * Forward clicks on award requests to the Award application.
 * @param {Event} event  The click event triggering the action.
 * @returns {Promise<void>}
 */
async function awardAction(event) {
  const target = event.target.closest('[data-action="awardRequest"]');
  const command = target?.closest("[data-award-command]")?.dataset.awardCommand;
  if ( !command ) return;
  event.stopPropagation();
  Award.handleAward(command);
}

/* -------------------------------------------- */

/**
 * Perform the provided roll action.
 * @param {Event} event  The click event triggering the action.
 * @returns {Promise}
 */
async function rollAction(event) {
  const target = event.target.closest('.roll-link-group, [data-action="rollRequest"], [data-action="concentration"]');
  if ( !target ) return;
  event.stopPropagation();

  const dataset = {
    ...((event.target.closest(".roll-link-group") ?? target)?.dataset ?? {}),
    ...(event.target.closest(".roll-link")?.dataset ?? {})
  };
  const { type, ability, skill, tool, dc } = dataset;
  const options = { event };
  if ( ability in CONFIG.DND5E.abilities ) options.ability = ability;
  if ( dc ) options.target = Number(dc);

  const action = event.target.closest("a")?.dataset.action ?? "roll";
  const link = event.target.closest("a") ?? event.target;

  // Direct roll
  if ( (action === "roll") || !game.user.isGM ) {
    link.disabled = true;
    try {
      switch ( type ) {
        case "attack": return await rollAttack(event);
        case "damage": return await rollDamage(event);
        case "item": return await useItem(dataset);
      }

      const actors = getSceneTargets().map(t => t.actor);
      if ( !actors.length && game.user.character ) actors.push(game.user.character);
      if ( !actors.length ) {
        ui.notifications.warn("EDITOR.DND5E.Inline.Warning.NoActor", { localize: true });
        return;
      }

      for ( const actor of actors ) {
        switch ( type ) {
          case "check":
            await actor.rollAbilityCheck(options);
            break;
          case "concentration":
            await actor.rollConcentration({ ...options, legacy: false });
            break;
          case "save":
            await actor.rollSavingThrow(options);
            break;
          case "skill":
            await actor.rollSkill({ skill, ...options });
            break;
          case "tool":
            await actor.rollToolCheck({ tool, ...options });
            break;
        }
      }
    } finally {
      link.disabled = false;
    }
  }

  // Roll request
  else {
    const MessageClass = getDocumentClass("ChatMessage");

    let buttons;
    if ( dataset.type === "check" ) buttons = createCheckRequestButtons(dataset);
    else if ( dataset.type === "save" ) buttons = createSaveRequestButtons(dataset);
    else buttons = [createRequestButton({ ...dataset, format: "short" })];

    const chatData = {
      user: game.user.id,
      content: await renderTemplate("systems/dnd5e/templates/chat/request-card.hbs", { buttons }),
      flavor: game.i18n.localize("EDITOR.DND5E.Inline.RollRequest"),
      speaker: MessageClass.getSpeaker({user: game.user})
    };
    return MessageClass.create(chatData);
  }
}

/* -------------------------------------------- */

/**
 * Create a button for a chat request.
 * @param {object} dataset
 * @returns {object}
 */
function createRequestButton(dataset) {
  return {
    buttonLabel: createRollLabel({ ...dataset, icon: true }),
    hiddenLabel: createRollLabel({ ...dataset, icon: true, hideDC: true }),
    dataset: { ...dataset, action: "rollRequest", visibility: "all" }
  };
}

/* -------------------------------------------- */

/**
 * Perform an attack roll.
 * @param {Event} event     The click event triggering the action.
 * @returns {Promise|void}
 */
async function rollAttack(event) {
  const target = event.target.closest(".roll-link-group");
  const { activityUuid, attackMode, formula } = target.dataset;

  if ( activityUuid ) {
    const activity = await fromUuid(activityUuid);
    if ( activity ) return activity.rollAttack({ attackMode, event });
  }

  const targets = getTargetDescriptors();
  const rollConfig = {
    attackMode, event,
    hookNames: ["attack", "d20Test"],
    rolls: [{
      parts: [formula.replace(/^\s*\+\s*/, "")],
      options: {
        target: targets.length === 1 ? targets[0].ac : undefined
      }
    }]
  };

  const dialogConfig = {
    applicationClass: AttackRollConfigurationDialog
  };

  const messageConfig = {
    data: {
      flags: {
        dnd5e: {
          messageType: "roll",
          roll: { type: "attack" }
        }
      },
      flavor: game.i18n.localize("DND5E.AttackRoll"),
      speaker: ChatMessage.implementation.getSpeaker()
    }
  };

  const rolls = await CONFIG.Dice.D20Roll.build(rollConfig, dialogConfig, messageConfig);
  if ( rolls?.length ) {
    Hooks.callAll("dnd5e.rollAttackV2", rolls, { subject: null, ammoUpdate: null });
    Hooks.callAll("dnd5e.postRollAttack", rolls, { subject: null });
  }
}

/* -------------------------------------------- */

/**
 * Perform a damage roll.
 * @param {Event} event  The click event triggering the action.
 * @returns {Promise<void>}
 */
async function rollDamage(event) {
  const target = event.target.closest(".roll-link-group");
  let { activityUuid, attackMode, formulas, damageTypes, rollType } = target.dataset;

  if ( activityUuid ) {
    const activity = await fromUuid(activityUuid);
    if ( activity ) return activity.rollDamage({ attackMode, event });
  }

  formulas = formulas?.split("&") ?? [];
  damageTypes = damageTypes?.split("&") ?? [];

  const rollConfig = {
    attackMode, event,
    hookNames: ["damage"],
    rolls: formulas.map((formula, idx) => {
      const types = damageTypes[idx]?.split("|") ?? [];
      return {
        parts: [formula],
        options: { type: types[0], types }
      };
    })
  };

  const messageConfig = {
    create: true,
    data: {
      flags: {
        dnd5e: {
          messageType: "roll",
          roll: { type: rollType },
          targets: getTargetDescriptors()
        }
      },
      flavor: game.i18n.localize(`DND5E.${rollType === "healing" ? "Healing" : "Damage"}Roll`),
      speaker: ChatMessage.implementation.getSpeaker()
    }
  };

  const rolls = await CONFIG.Dice.DamageRoll.build(rollConfig, {}, messageConfig);
  if ( !rolls?.length ) return;
  Hooks.callAll("dnd5e.rollDamageV2", rolls);
}

/* -------------------------------------------- */

/**
 * Use an Item from an Item enricher.
 * @param {object} [options]
 * @param {string} [options.rollActivityUuid]  Lookup the Activity by UUID.
 * @param {string} [options.rollActivityName]  Lookup the Activity by name.
 * @param {string} [options.rollItemUuid]      Lookup the Item by UUID.
 * @param {string} [options.rollItemName]      Lookup the Item by name.
 * @param {string} [options.rollItemActor]     The UUID of a specific Actor that should use the Item.
 * @returns {Promise}
 */
async function useItem({ rollActivityUuid, rollActivityName, rollItemUuid, rollItemName, rollItemActor }={}) {
  // If UUID is provided, always roll that item directly
  if ( rollActivityUuid ) return (await fromUuid(rollActivityUuid))?.use();
  if ( rollItemUuid ) return (await fromUuid(rollItemUuid))?.use({ legacy: false });

  if ( !rollItemName ) return;
  const actor = rollItemActor ? await fromUuid(rollItemActor) : null;

  // If no actor is specified or player isn't owner, fall back to the macro rolling logic
  if ( !actor?.isOwner ) return rollItem(rollItemName, { activityName: rollActivityName });
  const token = canvas.tokens.controlled[0];

  // If a token is controlled, and it has an item with the correct name, activate it
  let item = token?.actor.items.getName(rollItemName);

  // Otherwise check the specified actor for the item
  if ( !item ) {
    item = actor.items.getName(rollItemName);

    // Display a warning to indicate the item wasn't rolled from the controlled actor
    if ( item && canvas.tokens.controlled.length ) ui.notifications.warn(
      game.i18n.format("MACRO.5eMissingTargetWarn", {
        actor: token.name, name: rollItemName, type: game.i18n.localize("DOCUMENT.Item")
      })
    );
  }

  if ( item ) {
    if ( rollActivityName ) {
      const activity = item.system.activities?.getName(rollActivityName);
      if ( activity ) return activity.use();

      // If no activity could be found at all, display a warning
      else ui.notifications.warn(game.i18n.format("EDITOR.DND5E.Inline.Warning.NoActivityOnItem", {
        activity: rollActivityName, actor: actor.name, item: rollItemName
      }));
    }

    else return item.use({ legacy: false });
  }

  // If no item could be found at all, display a warning
  else ui.notifications.warn(game.i18n.format("EDITOR.DND5E.Inline.Warning.NoItemOnActor", {
    actor: actor.name, item: rollItemName
  }));
}

var enrichers = /*#__PURE__*/Object.freeze({
  __proto__: null,
  createRollLabel: createRollLabel,
  registerCustomEnrichers: registerCustomEnrichers
});

/**
 * FIXME: Remove when v12 support dropped or https://github.com/foundryvtt/foundryvtt/issues/11991 backported.
 * Should NOT be exported for general use.
 * @ignore
 */
function parseUuid(uuid, {relative}={}) {
  if ( game.release.generation > 12 ) return foundry.utils.parseUuid(uuid, { relative });
  if ( !uuid ) throw new Error("A UUID string is required.");
  if ( uuid.startsWith(".") && relative ) return _resolveRelativeUuid(uuid, relative);
  const parsed = foundry.utils.parseUuid(uuid, {relative});
  if ( !parsed?.collection ) return parsed;
  const remappedUuid = uuid.startsWith("Compendium") ? [
    "Compendium",
    parsed.collection.metadata.id,
    parsed.primaryType ?? parsed.documentType,
    parsed.primaryId ?? parsed.documentId,
    ...parsed.embedded
  ].join(".") : uuid;
  return { ...parsed, uuid: remappedUuid };
}

/** @ignore */
function _resolveRelativeUuid(uuid, relative) {
  if ( !(relative instanceof foundry.abstract.Document) ) {
    throw new Error("A relative Document instance must be provided to _resolveRelativeUuid");
  }
  uuid = uuid.substring(1);
  const parts = uuid.split(".");
  if ( !parts.length ) throw new Error("Invalid relative UUID");
  let id;
  let type;
  let root;
  let primaryType;
  let primaryId;
  let collection;

  // Identify the root document and its collection
  const getRoot = doc => {
    if ( doc.parent ) parts.unshift(doc.documentName, doc.id);
    return doc.parent ? getRoot(doc.parent) : doc;
  };

  // Even-numbered parts include an explicit child document type
  if ( (parts.length % 2) === 0 ) {
    root = getRoot(relative);
    id = parts.at(-1);
    type = parts.at(-2);
    primaryType = root.documentName;
    primaryId = root.id;
    uuid = [primaryType, primaryId, ...parts].join(".");
  }

  // Relative Embedded Document
  else if ( relative.parent ) {
    id = parts.at(-1);
    type = relative.documentName;
    parts.unshift(type);
    root = getRoot(relative.parent);
    primaryType = root.documentName;
    primaryId = root.id;
    uuid = [primaryType, primaryId, ...parts].join(".");
  }

  // Relative Document
  else {
    root = relative;
    id = parts.pop();
    type = relative.documentName;
    uuid = [type, id].join(".");
  }

  // Recreate fully-qualified UUID and return the resolved result
  collection = root.pack ? root.compendium : root.collection;
  if ( root.pack ) uuid = `Compendium.${root.pack}.${uuid}`;
  return {uuid, type, id, collection, primaryType, primaryId, embedded: parts,
    documentType: primaryType ?? type, documentId: primaryId ?? id};
}

const { ObjectField, SchemaField: SchemaField$C, SetField: SetField$o, StringField: StringField$P } = foundry.data.fields;

/**
 * Extend the base ActiveEffect class to implement system-specific logic.
 */
class ActiveEffect5e extends ActiveEffect {
  /**
   * Static ActiveEffect ID for various conditions.
   * @type {Record<string, string>}
   */
  static ID = {
    BLOODIED: staticID("dnd5ebloodied"),
    ENCUMBERED: staticID("dnd5eencumbered"),
    EXHAUSTION: staticID("dnd5eexhaustion")
  };

  /* -------------------------------------------- */

  /**
   * Additional key paths to properties added during base data preparation that should be treated as formula fields.
   * @type {Set<string>}
   */
  static FORMULA_FIELDS = new Set([
    "system.attributes.ac.bonus",
    "system.attributes.ac.min",
    "system.attributes.encumbrance.bonuses.encumbered",
    "system.attributes.encumbrance.bonuses.heavilyEncumbered",
    "system.attributes.encumbrance.bonuses.maximum",
    "system.attributes.encumbrance.bonuses.overall",
    "system.attributes.encumbrance.multipliers.encumbered",
    "system.attributes.encumbrance.multipliers.heavilyEncumbered",
    "system.attributes.encumbrance.multipliers.maximum",
    "system.attributes.encumbrance.multipliers.overall",
    "save.dc.bonus"
  ]);

  /* -------------------------------------------- */

  /** @inheritdoc */
  static LOCALIZATION_PREFIXES = [...super.LOCALIZATION_PREFIXES, "DND5E.ACTIVEEFFECT"];

  /* -------------------------------------------- */

  /**
   * Is this effect an enchantment on an item that accepts enchantment?
   * @type {boolean}
   */
  get isAppliedEnchantment() {
    return (this.type === "enchantment") && !!this.origin && (this.origin !== this.parent.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Should this status effect be hidden from the current user?
   * @type {boolean}
   */
  get isConcealed() {
    if ( this.target?.testUserPermission(game.user, "OBSERVER") ) return false;

    // Hide bloodied status effect from players unless the token is friendly
    if ( (this.id === this.constructor.ID.BLOODIED) && (game.settings.get("dnd5e", "bloodied") === "player") ) {
      return this.target?.token?.disposition !== foundry.CONST.TOKEN_DISPOSITIONS.FRIENDLY;
    }

    return false;
  }

  /* -------------------------------------------- */

  /**
   * Is this active effect currently suppressed?
   * @type {boolean}
   */
  isSuppressed = false;

  /* -------------------------------------------- */

  /** @inheritDoc */
  get isTemporary() {
    return super.isTemporary && !this.isConcealed;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the source Actor or Item, or null if it could not be determined.
   * @returns {Promise<Actor5e|Item5e|null>}
   */
  async getSource() {
    if ( (this.target instanceof dnd5e.documents.Actor5e) && (this.parent instanceof dnd5e.documents.Item5e) ) {
      return this.parent;
    }
    return fromUuid(this.origin);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async _fromStatusEffect(statusId, { reference, ...effectData }, options) {
    if ( !("description" in effectData) && reference ) effectData.description = `@Embed[${reference} inline]`;
    return super._fromStatusEffect?.(statusId, effectData, options) ?? new this(effectData, options);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options={}) {
    if ( data instanceof foundry.abstract.DataModel ) data = data.toObject();

    if ( data.flags?.dnd5e?.type === "enchantment" ) {
      data.type = "enchantment";
      delete data.flags.dnd5e.type;
    }

    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */
  /*  Effect Application                          */
  /* -------------------------------------------- */

  /** @inheritDoc */
  apply(doc, change) {
    // Ensure changes targeting flags use the proper types
    if ( change.key.startsWith("flags.dnd5e.") ) change = this._prepareFlagChange(doc, change);

    // Properly handle formulas that don't exist as part of the data model
    if ( ActiveEffect5e.FORMULA_FIELDS.has(change.key) ) {
      const field = new FormulaField({ deterministic: true });
      return { [change.key]: this.constructor.applyField(doc, change, field) };
    }

    // Handle activity-targeted changes
    if ( (change.key.startsWith("activities[") || change.key.startsWith("system.activities."))
      && (doc instanceof Item) ) return this.applyActivity(doc, change);

    return super.apply(doc, change);
  }

  /* -------------------------------------------- */

  /**
   * Apply a change to activities on this item.
   * @param {Item5e} item              The Item to whom this change should be applied.
   * @param {EffectChangeData} change  The change data being applied.
   * @returns {Record<string, *>}      An object of property paths and their updated values.
   */
  applyActivity(item, change) {
    const changes = {};
    const apply = (activity, key) => {
      const c = this.apply(activity, { ...change, key });
      Object.entries(c).forEach(([k, v]) => changes[`system.activities.${activity.id}.${k}`] = v);
    };
    if ( change.key.startsWith("system.activities.") ) {
      const [, , id, ...keyPath] = change.key.split(".");
      const activity = item.system.activities?.get(id);
      if ( activity ) apply(activity, keyPath.join("."));
    } else {
      const { type, key } = change.key.match(/activities\[(?<type>[^\]]+)]\.(?<key>.+)/)?.groups ?? {};
      item.system.activities?.getByType(type)?.forEach(activity => apply(activity, key));
    }
    return changes;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static applyField(model, change, field) {
    field ??= model.schema.getField(change.key);
    change = foundry.utils.deepClone(change);
    const current = foundry.utils.getProperty(model, change.key);
    const modes = CONST.ACTIVE_EFFECT_MODES;

    // Replace value when using string interpolation syntax
    if ( (field instanceof StringField$P) && (change.mode === modes.OVERRIDE) && change.value.includes("{}") ) {
      change.value = change.value.replace("{}", current ?? "");
    }

    // If current value is `null`, UPGRADE & DOWNGRADE should always just set the value
    if ( (current === null) && [modes.UPGRADE, modes.DOWNGRADE].includes(change.mode) ) change.mode = modes.OVERRIDE;

    // Handle removing entries from sets
    if ( (field instanceof SetField$o) && (change.mode === modes.ADD) && (foundry.utils.getType(current) === "Set") ) {
      for ( const value of field._castChangeDelta(change.value) ) {
        const neg = value.replace(/^\s*-\s*/, "");
        if ( neg !== value ) current.delete(neg);
        else current.add(value);
      }
      return current;
    }

    // If attempting to apply active effect to empty MappingField entry, create it
    if ( (current === undefined) && change.key.startsWith("system.") ) {
      let keyPath = change.key;
      let mappingField = field;
      while ( !(mappingField instanceof MappingField) && mappingField ) {
        if ( mappingField.name ) keyPath = keyPath.substring(0, keyPath.length - mappingField.name.length - 1);
        mappingField = mappingField.parent;
      }
      if ( mappingField && (foundry.utils.getProperty(model, keyPath) === undefined) ) {
        const created = mappingField.model.initialize(mappingField.model.getInitialValue(), mappingField);
        foundry.utils.setProperty(model, keyPath, created);
      }
    }

    // Parse any JSON provided when targeting an object
    if ( (field instanceof ObjectField) || (field instanceof SchemaField$C) ) {
      change = { ...change, value: this.prototype._parseOrString(change.value) };
    }

    return super.applyField(model, change, field);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyAdd(actor, change, current, delta, changes) {
    if ( current instanceof Set ) {
      const handle = v => {
        const neg = v.replace(/^\s*-\s*/, "");
        if ( neg !== v ) current.delete(neg);
        else current.add(v);
      };
      if ( Array.isArray(delta) ) delta.forEach(item => handle(item));
      else handle(delta);
      return;
    }
    super._applyAdd(actor, change, current, delta, changes);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyLegacy(actor, change, changes) {
    if ( this.system._applyLegacy?.(actor, change, changes) === false ) return;
    super._applyLegacy(actor, change, changes);
  }

  /* --------------------------------------------- */

  /** @inheritDoc */
  _applyUpgrade(actor, change, current, delta, changes) {
    if ( current === null ) return this._applyOverride(actor, change, current, delta, changes);
    return super._applyUpgrade(actor, change, current, delta, changes);
  }

  /* --------------------------------------------- */

  /**
   * Transform the data type of the change to match the type expected for flags.
   * @param {Actor5e} actor            The Actor to whom this effect should be applied.
   * @param {EffectChangeData} change  The change being applied.
   * @returns {EffectChangeData}       The change with altered types if necessary.
   */
  _prepareFlagChange(actor, change) {
    const { key, value } = change;
    const data = CONFIG.DND5E.characterFlags[key.replace("flags.dnd5e.", "")];
    if ( !data ) return change;

    // Set flag to initial value if it isn't present
    const current = foundry.utils.getProperty(actor, key) ?? null;
    if ( current === null ) {
      let initialValue = null;
      if ( data.placeholder ) initialValue = data.placeholder;
      else if ( data.type === Boolean ) initialValue = false;
      else if ( data.type === Number ) initialValue = 0;
      foundry.utils.setProperty(actor, key, initialValue);
    }

    // Coerce change data into the correct type
    if ( data.type === Boolean ) {
      if ( value === "false" ) change.value = false;
      else change.value = Boolean(value);
    }
    return change;
  }

  /* --------------------------------------------- */

  /**
   * Determine whether this Active Effect is suppressed or not.
   */
  determineSuppression() {
    this.isSuppressed = false;
    if ( this.type === "enchantment" ) return;
    if ( this.parent instanceof dnd5e.documents.Item5e ) this.isSuppressed = this.parent.areEffectsSuppressed;
  }

  /* -------------------------------------------- */
  /*  Lifecycle                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    if ( this.id === this.constructor.ID.EXHAUSTION ) this._prepareExhaustionLevel();
    if ( this.isAppliedEnchantment ) dnd5e.registry.enchantments.track(this.origin, this.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Modify the ActiveEffect's attributes based on the exhaustion level.
   * @protected
   */
  _prepareExhaustionLevel() {
    const config = CONFIG.DND5E.conditionTypes.exhaustion;
    let level = this.getFlag("dnd5e", "exhaustionLevel");
    if ( !Number.isFinite(level) ) level = 1;
    this.img = this.constructor._getExhaustionImage(level);
    this.name = `${game.i18n.localize("DND5E.Exhaustion")} ${level}`;
    if ( level >= config.levels ) {
      this.statuses.add("dead");
      CONFIG.DND5E.statusEffects.dead.statuses?.forEach(s => this.statuses.add(s));
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare effect favorite data.
   * @returns {Promise<FavoriteData5e>}
   */
  async getFavoriteData() {
    return {
      img: this.img,
      title: this.name,
      subtitle: this.duration.remaining ? this.duration.label : "",
      toggle: !this.disabled,
      suppressed: this.isSuppressed
    };
  }

  /* -------------------------------------------- */

  /**
   * Create conditions that are applied separately from an effect.
   * @returns {Promise<ActiveEffect5e[]>}      Created rider effects.
   */
  async createRiderConditions() {
    const riders = new Set();

    for ( const status of this.getFlag("dnd5e", "riders.statuses") ?? [] ) {
      riders.add(status);
    }

    for ( const status of this.statuses ) {
      const r = CONFIG.statusEffects.find(e => e.id === status)?.riders ?? [];
      for ( const p of r ) riders.add(p);
    }

    if ( !riders.size ) return [];

    const createRider = async id => {
      const existing = this.parent.effects.get(staticID(`dnd5e${id}`));
      if ( existing ) return;
      const effect = await ActiveEffect5e.fromStatusEffect(id);
      return effect.toObject();
    };

    const effectData = await Promise.all(Array.from(riders).map(createRider));
    return ActiveEffect5e.createDocuments(effectData.filter(_ => _), { keepId: true, parent: this.parent });
  }

  /* -------------------------------------------- */

  /**
   * Create additional activities, effects, and items that are applied separately from an enchantment.
   * @param {object} options  Options passed to the effect creation.
   */
  async createRiderEnchantments(options={}) {
    let item;
    let profile;
    const { chatMessageOrigin } = options;
    const { enchantmentProfile, activityId } = options.dnd5e ?? {};

    if ( chatMessageOrigin ) {
      const message = game.messages.get(options?.chatMessageOrigin);
      item = message?.getAssociatedItem();
      const activity = message?.getAssociatedActivity();
      profile = activity?.effects.find(e => e._id === message?.getFlag("dnd5e", "use.enchantmentProfile"));
    } else if ( enchantmentProfile && activityId ) {
      let activity;
      const origin = await fromUuid(this.origin);
      if ( origin instanceof dnd5e.documents.activity.EnchantActivity ) {
        activity = origin;
        item = activity.item;
      } else if ( origin instanceof Item ) {
        item = origin;
        activity = item.system.activities?.get(activityId);
      }
      profile = activity?.effects.find(e => e._id === enchantmentProfile);
    }

    if ( !profile || !item ) return;

    // Create Activities
    const riderActivities = {};
    let riderEffects = [];
    for ( const id of profile.riders.activity ) {
      const activityData = item.system.activities.get(id)?.toObject();
      if ( !activityData ) continue;
      activityData._id = foundry.utils.randomID();
      riderActivities[activityData._id] = activityData;
    }
    let createdActivities = [];
    if ( !foundry.utils.isEmpty(riderActivities) ) {
      await this.parent.update({ "system.activities": riderActivities });
      createdActivities = Object.keys(riderActivities).map(id => this.parent.system.activities?.get(id));
      createdActivities.forEach(a => a.effects?.forEach(e => {
        if ( !this.parent.effects.has(e._id) ) riderEffects.push(item.effects.get(e._id)?.toObject());
      }));
    }

    // Create Effects
    riderEffects.push(...profile.riders.effect.map(id => {
      const effectData = item.effects.get(id)?.toObject();
      if ( effectData ) {
        delete effectData._id;
        delete effectData.flags?.dnd5e?.rider;
        effectData.origin = this.origin;
      }
      return effectData;
    }));
    riderEffects = riderEffects.filter(_ => _);
    const createdEffects = await this.parent.createEmbeddedDocuments("ActiveEffect", riderEffects, { keepId: true });

    // Create Items
    let createdItems = [];
    if ( this.parent.isEmbedded ) {
      const riderItems = await Promise.all(profile.riders.item.map(async uuid => {
        const itemData = (await fromUuid(uuid))?.toObject();
        if ( itemData ) {
          delete itemData._id;
          foundry.utils.setProperty(itemData, "flags.dnd5e.enchantment", { origin: this.uuid });
        }
        return itemData;
      }));
      createdItems = await this.parent.actor.createEmbeddedDocuments("Item", riderItems.filter(i => i));
    }

    if ( createdActivities.length || createdEffects.length || createdItems.length ) {
      this.addDependent(...createdActivities, ...createdEffects, ...createdItems);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  toDragData() {
    const data = super.toDragData();
    const activity = this.parent?.system.activities?.getByType("enchant").find(a => {
      return a.effects.some(e => e._id === this.id);
    });
    if ( activity ) data.activityId = activity.id;
    return data;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( await super._preCreate(data, options, user) === false ) return false;
    if ( options.keepOrigin === false ) this.updateSource({ origin: this.parent.uuid });

    // Enchantments cannot be added directly to actors
    if ( (this.type === "enchantment") && (this.parent instanceof Actor) ) {
      ui.notifications.error("DND5E.ENCHANTMENT.Warning.NotOnActor", { localize: true });
      return false;
    }

    if ( this.isAppliedEnchantment ) {
      const origin = await fromUuid(this.origin);
      const errors = origin?.canEnchant?.(this.parent);
      if ( errors?.length ) {
        errors.forEach(err => console.error(err));
        return false;
      }
      this.updateSource({ disabled: false });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( userId === game.userId ) {
      if ( this.active && (this.parent instanceof Actor) ) await this.createRiderConditions();
      if ( this.isAppliedEnchantment ) await this.createRiderEnchantments(options);
    }
    if ( options.chatMessageOrigin ) {
      document.body.querySelectorAll(`[data-message-id="${options.chatMessageOrigin}"] enchantment-application`)
        .forEach(element => element.buildItemList());
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(data, options, userId) {
    super._onUpdate(data, options, userId);
    const originalLevel = foundry.utils.getProperty(options, "dnd5e.originalExhaustion");
    const newLevel = foundry.utils.getProperty(data, "flags.dnd5e.exhaustionLevel");
    const originalEncumbrance = foundry.utils.getProperty(options, "dnd5e.originalEncumbrance");
    const newEncumbrance = data.statuses?.[0];
    const name = this.name;

    // Display proper scrolling status effects for exhaustion
    if ( (this.id === this.constructor.ID.EXHAUSTION) && Number.isFinite(newLevel) && Number.isFinite(originalLevel) ) {
      if ( newLevel === originalLevel ) return;
      // Temporarily set the name for the benefit of _displayScrollingTextStatus. We should improve this method to
      // accept a name parameter instead.
      if ( newLevel < originalLevel ) this.name = `Exhaustion ${originalLevel}`;
      this._displayScrollingStatus(newLevel > originalLevel);
      this.name = name;
    }

    // Display proper scrolling status effects for encumbrance
    else if ( (this.id === this.constructor.ID.ENCUMBERED) && originalEncumbrance && newEncumbrance ) {
      if ( newEncumbrance === originalEncumbrance ) return;
      const increase = !originalEncumbrance || ((originalEncumbrance === "encumbered") && newEncumbrance)
        || (newEncumbrance === "exceedingCarryingCapacity");
      if ( !increase ) this.name = CONFIG.DND5E.encumbrance.effects[originalEncumbrance].name;
      this._displayScrollingStatus(increase);
      this.name = name;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preDelete(options, user) {
    const dependents = this.getDependents();
    if ( dependents.length && !game.users.activeGM ) {
      ui.notifications.warn("DND5E.ConcentrationBreakWarning", { localize: true });
      return false;
    }
    return super._preDelete(options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( game.user === game.users.activeGM ) this.getDependents().forEach(e => e.delete());
    if ( this.isAppliedEnchantment ) dnd5e.registry.enchantments.untrack(this.origin, this.uuid);
    document.body.querySelectorAll(`enchantment-application:has([data-enchantment-uuid="${this.uuid}"]`)
      .forEach(element => element.buildItemList());
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _displayScrollingStatus(enabled) {
    if ( this.isConcealed ) return;
    super._displayScrollingStatus(enabled);
  }

  /* -------------------------------------------- */
  /*  Exhaustion and Concentration Handling       */
  /* -------------------------------------------- */

  /**
   * Create effect data for concentration on an actor.
   * @param {Activity} activity  The Activity on which to begin concentrating.
   * @param {object} [data]      Additional data provided for the effect instance.
   * @returns {object}           Created data for the ActiveEffect.
   */
  static createConcentrationEffectData(activity, data={}) {
    if ( activity instanceof Item ) {
      foundry.utils.logCompatibilityWarning(
        "The `createConcentrationEffectData` method on ActiveEffect5e now takes an Activity, rather than an Item.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      activity = activity.system.activities?.contents[0];
    }

    const item = activity?.item;
    if ( !item?.isEmbedded || !activity.duration.concentration ) {
      throw new Error("You may not begin concentrating on this item!");
    }

    const statusEffect = CONFIG.statusEffects.find(e => e.id === CONFIG.specialStatusEffects.CONCENTRATING);
    const effectData = foundry.utils.mergeObject({
      ...statusEffect,
      name: `${game.i18n.localize("EFFECT.DND5E.StatusConcentrating")}: ${item.name}`,
      description: `<p>${game.i18n.format("DND5E.ConcentratingOn", {
        name: item.name,
        type: game.i18n.localize(`TYPES.Item.${item.type}`)
      })}</p><hr><p>@Embed[${item.uuid} inline]</p>`,
      duration: activity.duration.getEffectData(),
      "flags.dnd5e": {
        activity: {
          type: activity.type, id: activity.id, uuid: activity.uuid
        },
        item: {
          type: item.type, id: item.id, uuid: item.uuid,
          data: !item.actor.items.has(item.id) ? item.toObject() : undefined
        }
      },
      origin: item.uuid,
      statuses: [statusEffect.id].concat(statusEffect.statuses ?? [])
    }, data, {inplace: false});
    delete effectData.id;
    if ( item.type === "spell" ) effectData["flags.dnd5e.spellLevel"] = item.system.level;

    return effectData;
  }

  /* -------------------------------------------- */

  /**
   * Register listeners for custom handling in the TokenHUD.
   */
  static registerHUDListeners() {
    Hooks.on("renderTokenHUD", this.onTokenHUDRender);
    document.addEventListener("click", this.onClickTokenHUD.bind(this), { capture: true });
    document.addEventListener("contextmenu", this.onClickTokenHUD.bind(this), { capture: true });
  }

  /* -------------------------------------------- */

  /**
   * Add modifications to the core ActiveEffect config.
   * @param {ActiveEffectConfig} app   The ActiveEffect config.
   * @param {jQuery|HTMLElement} html  The ActiveEffect config element.
   */
  static onRenderActiveEffectConfig(app, html) {
    if ( game.release.generation < 13 ) html = html[0];
    const element = new foundry.data.fields.SetField(new foundry.data.fields.StringField(), {}).toFormGroup({
      label: game.i18n.localize("DND5E.CONDITIONS.RiderConditions.label"),
      hint: game.i18n.localize("DND5E.CONDITIONS.RiderConditions.hint")
    }, {
      name: "flags.dnd5e.riders.statuses",
      value: app.document.getFlag("dnd5e", "riders.statuses") ?? [],
      options: CONFIG.statusEffects.map(se => ({ value: se.id, label: se.name }))
    });
    // TODO: Temporary fix to work around https://github.com/foundryvtt/foundryvtt/issues/11567
    // Replace with `after` when switched to V13-only
    html.querySelector("[data-tab=details] > .form-group:has([name=statuses])")
      ?.insertAdjacentHTML("afterend", element.outerHTML);

    if ( game.release.generation < 13 ) {
      html.querySelector(".form-fields:has([name=statuses])").insertAdjacentHTML("afterend", `
        <p class="hint">${app.document.schema.fields.statuses.hint}</p>
      `);
      app.setPosition();
    }
  }

  /* -------------------------------------------- */

  /**
   * Adjust exhaustion icon display to match current level.
   * @param {Application} app  The TokenHUD application.
   * @param {jQuery} html      The TokenHUD HTML.
   */
  static onTokenHUDRender(app, html) {
    const actor = app.object.actor;
    const level = foundry.utils.getProperty(actor, "system.attributes.exhaustion");
    if ( Number.isFinite(level) && (level > 0) ) {
      const img = ActiveEffect5e._getExhaustionImage(level);
      html.find('[data-status-id="exhaustion"]').css({
        objectPosition: "-100px",
        background: `url('${img}') no-repeat center / contain`
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Get the image used to represent exhaustion at this level.
   * @param {number} level
   * @returns {string}
   */
  static _getExhaustionImage(level) {
    const split = CONFIG.DND5E.conditionTypes.exhaustion.icon.split(".");
    const ext = split.pop();
    const path = split.join(".");
    return `${path}-${level}.${ext}`;
  }

  /* -------------------------------------------- */

  /**
   * Map the duration of an item to an active effect duration.
   * @param {Item5e} item           An item with a duration.
   * @returns {EffectDurationData}  The active effect duration.
   */
  static getEffectDurationFromItem(item) {
    foundry.utils.logCompatibilityWarning(
      "The `getEffectDurationFromItem` method on ActiveEffect5e has been deprecated and replaced with `getEffectData` within Item or Activity duration.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );
    return item.system.duration?.getEffectData?.() ?? {};
  }

  /* -------------------------------------------- */

  /**
   * Implement custom behavior for select conditions on the token HUD.
   * @param {PointerEvent} event        The triggering event.
   */
  static onClickTokenHUD(event) {
    const { target } = event;
    if ( !target.classList?.contains("effect-control") ) return;

    const actor = canvas.hud.token.object?.actor;
    if ( !actor ) return;

    const id = target.dataset?.statusId;
    if ( id === "exhaustion" ) ActiveEffect5e._manageExhaustion(event, actor);
    else if ( id === "concentrating" ) ActiveEffect5e._manageConcentration(event, actor);
  }

  /* -------------------------------------------- */

  /**
   * Manage custom exhaustion cycling when interacting with the token HUD.
   * @param {PointerEvent} event        The triggering event.
   * @param {Actor5e} actor             The actor belonging to the token.
   */
  static _manageExhaustion(event, actor) {
    let level = foundry.utils.getProperty(actor, "system.attributes.exhaustion");
    if ( !Number.isFinite(level) ) return;
    event.preventDefault();
    event.stopPropagation();
    if ( event.button === 0 ) level++;
    else level--;
    const max = CONFIG.DND5E.conditionTypes.exhaustion.levels;
    actor.update({ "system.attributes.exhaustion": Math.clamp(level, 0, max) });
  }

  /* -------------------------------------------- */

  /**
   * Manage custom concentration handling when interacting with the token HUD.
   * @param {PointerEvent} event        The triggering event.
   * @param {Actor5e} actor             The actor belonging to the token.
   */
  static _manageConcentration(event, actor) {
    const { effects } = actor.concentration;
    if ( effects.size < 1 ) return;
    event.preventDefault();
    event.stopPropagation();
    if ( effects.size === 1 ) {
      actor.endConcentration(effects.first());
      return;
    }
    const choices = effects.reduce((acc, effect) => {
      const data = effect.getFlag("dnd5e", "item.data");
      acc[effect.id] = data?.name ?? actor.items.get(data)?.name ?? game.i18n.localize("DND5E.ConcentratingItemless");
      return acc;
    }, {});
    const options = HandlebarsHelpers.selectOptions(choices, { hash: { sort: true } });
    const content = `
    <form class="dnd5e">
      <p>${game.i18n.localize("DND5E.ConcentratingEndChoice")}</p>
      <div class="form-group">
        <label>${game.i18n.localize("DND5E.SOURCE.FIELDS.source.label")}</label>
        <div class="form-fields">
          <select name="source">${options}</select>
        </div>
      </div>
    </form>`;
    Dialog.prompt({
      content: content,
      callback: ([html]) => {
        const source = new FormDataExtended(html.querySelector("FORM")).object.source;
        if ( source ) actor.endConcentration(source);
      },
      rejectClose: false,
      title: game.i18n.localize("DND5E.Concentration"),
      label: game.i18n.localize("DND5E.Confirm")
    });
  }

  /* -------------------------------------------- */

  /**
   * Record another effect as a dependent of this one.
   * @param {...ActiveEffect5e} dependent  One or more dependent effects.
   * @returns {Promise<ActiveEffect5e>}
   */
  addDependent(...dependent) {
    const dependents = this.getFlag("dnd5e", "dependents") ?? [];
    dependents.push(...dependent.map(d => ({ uuid: d.uuid })));
    return this.setFlag("dnd5e", "dependents", dependents);
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a list of dependent effects.
   * @returns {Array<ActiveEffect5e|Item5e>}
   */
  getDependents() {
    return (this.getFlag("dnd5e", "dependents") || []).reduce((arr, { uuid }) => {
      let effect;
      // TODO: Remove this special casing once https://github.com/foundryvtt/foundryvtt/issues/11214 is resolved
      if ( this.parent.pack && uuid.includes(this.parent.uuid) ) {
        const [, embeddedName, id] = uuid.replace(this.parent.uuid, "").split(".");
        effect = this.parent.getEmbeddedDocument(embeddedName, id);
      }
      else effect = fromUuidSync(uuid, { strict: false });
      if ( effect ) arr.push(effect);
      return arr;
    }, []);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Helper method to add choices that have been overridden by an active effect. Used to determine what fields might
   * need to be disabled because they are overridden by an active effect in a way not easily determined by looking at
   * the `Document#overrides` data structure.
   * @param {Actor5e|Item5e} doc  Document from which to determine the overrides.
   * @param {string} prefix       The initial form prefix under which the choices are grouped.
   * @param {string} path         Path in document data.
   * @param {string[]} overrides  The list of fields that are currently modified by Active Effects. *Will be mutated.*
   */
  static addOverriddenChoices(doc, prefix, path, overrides) {
    const source = new Set(foundry.utils.getProperty(doc._source, path) ?? []);
    const current = foundry.utils.getProperty(doc, path) ?? new Set();
    const delta = current.symmetricDifference(source);
    for ( const choice of delta ) overrides.push(`${prefix}.${choice}`);
  }

  /* -------------------------------------------- */

  /**
   * Render a rich tooltip for this effect.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {Promise<{content: string, classes: string[]}>}
   */
  async richTooltip(enrichmentOptions={}) {
    const properties = [];
    if ( this.isSuppressed ) properties.push("DND5E.EffectType.Unavailable");
    else if ( this.disabled ) properties.push("DND5E.EffectType.Inactive");
    else if ( this.isTemporary ) properties.push("DND5E.EffectType.Temporary");
    else properties.push("DND5E.EffectType.Passive");
    if ( this.type === "enchantment" ) properties.push("DND5E.ENCHANTMENT.Label");

    return {
      content: await renderTemplate(
        "systems/dnd5e/templates/effects/parts/effect-tooltip.hbs", {
          effect: this,
          description: await TextEditor.enrichHTML(this.description ?? "", { relativeTo: this, ...enrichmentOptions }),
          durationParts: this.duration.remaining ? this.duration.label.split(", ") : [],
          properties: properties.map(p => game.i18n.localize(p))
        }
      ),
      classes: ["dnd5e2", "dnd5e-tooltip", "effect-tooltip"]
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async deleteDialog(dialogOptions={}, operation={}) {
    const type = game.i18n.localize(this.constructor.metadata.label);
    return foundry.applications.api.DialogV2.confirm(foundry.utils.mergeObject({
      window: { title: `${game.i18n.format("DOCUMENT.Delete", { type })}: ${this.name}` },
      position: { width: 400 },
      content: `
        <p>
            <strong>${game.i18n.localize("AreYouSure")}</strong> ${game.i18n.format("SIDEBAR.DeleteWarning", { type })}
        </p>
      `,
      yes: { callback: () => this.delete(operation) }
    }, dialogOptions));
  }
}

/**
 * Dialog for choosing an activity to use on an Item.
 */
class ActivityChoiceDialog extends Application5e {
  /**
   * @param {Item5e} item                         The Item whose activities are being chosen.
   * @param {ApplicationConfiguration} [options]  Application configuration options.
   */
  constructor(item, options={}) {
    super(options);
    this.#item = item;
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["activity-choice"],
    actions: {
      choose: ActivityChoiceDialog.#onChooseActivity
    },
    position: {
      width: 350
    }
  };

  static PARTS = {
    activities: {
      template: "systems/dnd5e/templates/activity/activity-choices.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The chosen activity.
   * @type {Activity|null}
   */
  get activity() {
    return this.#activity ?? null;
  }

  #activity;

  /* -------------------------------------------- */

  /**
   * The Item whose activities are being chosen.
   * @type {Item5e}
   */
  get item() {
    return this.#item;
  }

  #item;

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return this.#item.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( options.isFirstRender ) options.window.icon ||= this.#item.img;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    let controlHint;
    if ( game.settings.get("dnd5e", "controlHints") ) {
      controlHint = game.i18n.localize("DND5E.Controls.Activity.FastForwardHint");
      controlHint = controlHint.replace(
        "<left-click>",
        `<img src="systems/dnd5e/icons/svg/mouse-left.svg" alt="${game.i18n.localize("DND5E.Controls.LeftClick")}">`
      );
    }
    const activities = this.#item.system.activities
      .filter(a => !this.#item.getFlag("dnd5e", "riders.activity")?.includes(a.id) && a.canUse)
      .map(this._prepareActivityContext.bind(this))
      .sort((a, b) => a.sort - b.sort);
    return {
      ...await super._prepareContext(options),
      controlHint, activities
    };
  }

  /* -------------------------------------------- */

  /**
   * @typedef ActivityChoiceDialogContext
   * @property {string} id
   * @property {string} name
   * @property {number} sort
   * @property {object} icon
   * @property {string} icon.src
   * @property {boolean} icon.svg
   */

  /**
   * Prepare rendering context for a given activity.
   * @param {Activity} activity  The activity.
   * @returns {ActivityChoiceDialogContext}
   * @protected
   */
  _prepareActivityContext(activity) {
    const { id, name, img, sort } = activity;
    return {
      id, name, sort,
      icon: {
        src: img,
        svg: img.endsWith(".svg")
      }
    };
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle choosing an activity.
   * @this {ActivityChoiceDialog}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The activity button that was clicked.
   */
  static async #onChooseActivity(event, target) {
    const { activityId } = target.dataset;
    this.#activity = this.#item.system.activities.get(activityId);
    this.close();
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Display the activity choice dialog.
   * @param {Item5e} item                         The Item whose activities are being chosen.
   * @param {ApplicationConfiguration} [options]  Application configuration options.
   * @returns {Promise<Activity|null>}            The chosen activity, or null if the dialog was dismissed.
   */
  static create(item, options) {
    return new Promise(resolve => {
      const dialog = new this(item, options);
      dialog.addEventListener("close", () => resolve(dialog.activity), { once: true });
      dialog.render({ force: true });
    });
  }
}

/**
 * Internal type used to manage each step within the advancement process.
 *
 * @typedef {object} AdvancementStep
 * @property {string} type                Step type from "forward", "reverse", "restore", or "delete".
 * @property {AdvancementFlow} [flow]     Flow object for the advancement being applied by this step. In the case of
 *                                        "delete" steps, this flow indicates the advancement flow that originally
 *                                        deleted the item.
 * @property {Item5e} [item]              For "delete" steps only, the item to be removed.
 * @property {object} [class]             Contains data on class if step was triggered by class level change.
 * @property {Item5e} [class.item]        Class item that caused this advancement step.
 * @property {number} [class.level]       Level the class should be during this step.
 * @property {boolean} [automatic=false]  Should the manager attempt to apply this step without user interaction?
 * @property {boolean} [synthetic=false]  Was this step created as a result of an item introduced or deleted?
 */

/**
 * @typedef AdvancementManagerConfiguration
 * @property {boolean} [automaticApplication=false]  Apply advancement steps automatically if no user input is required.
 * @property {boolean} [showVisualizer=false]        Display the step debugging application.
 */

/**
 * Application for controlling the advancement workflow and displaying the interface.
 *
 * @param {Actor5e} actor        Actor on which this advancement is being performed.
 * @param {object} [options={}]  Additional application options.
 */
class AdvancementManager extends Application5e {
  constructor(actor, options={}) {
    super(options);
    this.actor = actor;
    this.clone = actor.clone();
    if ( this.options.showVisualizer ) this.#visualizer = new AdvancementVisualizer({ manager: this });
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["advancement", "manager"],
    window: {
      icon: "fa-solid fa-forward",
      title: "DND5E.ADVANCEMENT.Manager.Title.Default"
    },
    actions: {
      complete: AdvancementManager.#process,
      next: AdvancementManager.#process,
      previous: AdvancementManager.#process,
      restart: AdvancementManager.#process
    },
    position: {
      width: 460
    },
    automaticApplication: false,
    showVisualizer: false
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    manager: {
      template: "systems/dnd5e/templates/advancement/advancement-manager.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The original actor to which changes will be applied when the process is complete.
   * @type {Actor5e}
   */
  actor;

  /* -------------------------------------------- */

  /**
   * Is the prompt currently advancing through un-rendered steps?
   * @type {boolean}
   */
  #advancing = false;

  /* -------------------------------------------- */

  /**
   * A clone of the original actor to which the changes can be applied during the advancement process.
   * @type {Actor5e}
   */
  clone;

  /* -------------------------------------------- */

  /** @inheritDoc */
  get subtitle() {
    const parts = [];

    // Item Name
    const item = this.step.flow.item;
    parts.push(item.name);

    // Class/Subclass level
    let level = this.step.flow.level;
    if ( this.step.class && ["class", "subclass"].includes(item.type) ) level = this.step.class.level;
    if ( level ) parts.push(game.i18n.format("DND5E.AdvancementLevelHeader", { level }));

    // Step Count
    const visibleSteps = this.steps.filter(s => !s.automatic);
    const visibleIndex = visibleSteps.indexOf(this.step);
    if ( visibleIndex >= 0 ) parts.push(game.i18n.format("DND5E.ADVANCEMENT.Manager.Steps", {
      current: visibleIndex + 1,
      total: visibleSteps.length
    }));

    return parts.join(" • ");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get id() {
    return `actor-${this.actor.id}-advancement`;
  }

  /* -------------------------------------------- */

  /**
   * Get the step that is currently in progress.
   * @type {object|null}
   */
  get step() {
    return this.steps[this.#stepIndex] ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Step being currently displayed.
   * @type {number|null}
   */
  #stepIndex = null;

  /* -------------------------------------------- */

  /**
   * Individual steps that will be applied in order.
   * @type {AdvancementStep[]}
   */
  steps = [];

  /* -------------------------------------------- */

  /**
   * Get the step before the current one.
   * @type {object|null}
   */
  get previousStep() {
    return this.steps[this.#stepIndex - 1] ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Get the step after the current one.
   * @type {object|null}
   */
  get nextStep() {
    const nextIndex = this.#stepIndex === null ? 0 : this.#stepIndex + 1;
    return this.steps[nextIndex] ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Side application for debugging advancement steps.
   * @type {AdvancementVisualizer}
   */
  #visualizer;

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Construct a manager for a newly added advancement from drag-drop.
   * @param {Actor5e} actor               Actor from which the advancement should be updated.
   * @param {string} itemId               ID of the item to which the advancements are being dropped.
   * @param {Advancement[]} advancements  Dropped advancements to add.
   * @param {object} [options={}]         Rendering options passed to the application.
   * @returns {AdvancementManager}  Prepared manager. Steps count can be used to determine if advancements are needed.
   */
  static forNewAdvancement(actor, itemId, advancements, options={}) {
    const manager = new this(actor, options);
    const clonedItem = manager.clone.items.get(itemId);
    if ( !clonedItem || !advancements.length ) return manager;

    const currentLevel = this.currentLevel(clonedItem, manager.clone);
    const minimumLevel = advancements.reduce((min, a) => Math.min(a.levels[0] ?? Infinity, min), Infinity);
    if ( minimumLevel > currentLevel ) return manager;

    const oldFlows = Array.fromRange(currentLevel + 1).slice(minimumLevel)
      .flatMap(l => this.flowsForLevel(clonedItem, l));

    // Revert advancements through minimum level
    oldFlows.reverse().forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));

    // Add new advancements
    const advancementArray = clonedItem.toObject().system.advancement;
    advancementArray.push(...advancements.map(a => {
      const obj = a.toObject();
      if ( obj.constructor.dataModels?.value ) a.value = (new a.constructor.metadata.dataModels.value()).toObject();
      else obj.value = foundry.utils.deepClone(a.constructor.metadata.defaults?.value ?? {});
      return obj;
    }));
    clonedItem.updateSource({"system.advancement": advancementArray});

    const newFlows = Array.fromRange(currentLevel + 1).slice(minimumLevel)
      .flatMap(l => this.flowsForLevel(clonedItem, l));

    // Restore existing advancements and apply new advancements
    newFlows.forEach(flow => {
      const matchingFlow = oldFlows.find(f => (f.advancement.id === flow.advancement.id) && (f.level === flow.level));
      if ( matchingFlow ) manager.steps.push({ type: "restore", flow: matchingFlow, automatic: true });
      else manager.steps.push({ type: "forward", flow });
    });

    return manager;
  }

  /* -------------------------------------------- */

  /**
   * Construct a manager for a newly added item.
   * @param {Actor5e} actor         Actor to which the item is being added.
   * @param {object} itemData       Data for the item being added.
   * @param {object} [options={}]   Rendering options passed to the application.
   * @returns {AdvancementManager}  Prepared manager. Steps count can be used to determine if advancements are needed.
   */
  static forNewItem(actor, itemData, options={}) {
    const manager = new this(actor, options);

    // Prepare data for adding to clone
    const dataClone = foundry.utils.deepClone(itemData);
    dataClone._id = foundry.utils.randomID();
    if ( itemData.type === "class" ) {
      dataClone.system.levels = 0;
      if ( !manager.clone.system.details.originalClass ) {
        manager.clone.updateSource({"system.details.originalClass": dataClone._id});
      }
    }

    // Add item to clone & get new instance from clone
    manager.clone.updateSource({items: [dataClone]});
    const clonedItem = manager.clone.items.get(dataClone._id);

    // For class items, prepare level change data
    if ( itemData.type === "class" ) {
      return manager.createLevelChangeSteps(clonedItem, itemData.system?.levels ?? 1);
    }

    // All other items, just create some flows up to current character level (or class level for subclasses)
    let targetLevel = manager.clone.system.details.level ?? 0;
    if ( clonedItem.type === "subclass" ) targetLevel = clonedItem.class?.system.levels ?? 0;
    Array.fromRange(targetLevel + 1)
      .flatMap(l => this.flowsForLevel(clonedItem, l))
      .forEach(flow => manager.steps.push({ type: "forward", flow }));

    return manager;
  }

  /* -------------------------------------------- */

  /**
   * Construct a manager for modifying choices on an item at a specific level.
   * @param {Actor5e} actor         Actor from which the choices should be modified.
   * @param {object} itemId         ID of the item whose choices are to be changed.
   * @param {number} level          Level at which the choices are being changed.
   * @param {object} [options={}]   Rendering options passed to the application.
   * @returns {AdvancementManager}  Prepared manager. Steps count can be used to determine if advancements are needed.
   */
  static forModifyChoices(actor, itemId, level, options={}) {
    const manager = new this(actor, options);
    const clonedItem = manager.clone.items.get(itemId);
    if ( !clonedItem ) return manager;

    const flows = Array.fromRange(this.currentLevel(clonedItem, manager.clone) + 1).slice(level)
      .flatMap(l => this.flowsForLevel(clonedItem, l));

    // Revert advancements through changed level
    flows.reverse().forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));

    // Create forward advancements for level being changed
    flows.reverse().filter(f => f.level === level).forEach(flow => manager.steps.push({ type: "forward", flow }));

    // Create restore advancements for other levels
    flows.filter(f => f.level > level).forEach(flow => manager.steps.push({ type: "restore", flow, automatic: true }));

    return manager;
  }

  /* -------------------------------------------- */

  /**
   * Construct a manager for an advancement that needs to be deleted.
   * @param {Actor5e} actor         Actor from which the advancement should be unapplied.
   * @param {string} itemId         ID of the item from which the advancement should be deleted.
   * @param {string} advancementId  ID of the advancement to delete.
   * @param {object} [options={}]   Rendering options passed to the application.
   * @returns {AdvancementManager}  Prepared manager. Steps count can be used to determine if advancements are needed.
   */
  static forDeletedAdvancement(actor, itemId, advancementId, options={}) {
    const manager = new this(actor, options);
    const clonedItem = manager.clone.items.get(itemId);
    const advancement = clonedItem?.advancement.byId[advancementId];
    if ( !advancement ) return manager;

    const minimumLevel = advancement.levels[0];
    const currentLevel = this.currentLevel(clonedItem, manager.clone);

    // If minimum level is greater than current level, no changes to remove
    if ( (minimumLevel > currentLevel) || !advancement.appliesToClass ) return manager;

    advancement.levels
      .reverse()
      .filter(l => l <= currentLevel)
      .map(l => new advancement.constructor.metadata.apps.flow(clonedItem, advancementId, l))
      .forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));

    if ( manager.steps.length ) manager.steps.push({ type: "delete", advancement, automatic: true });

    return manager;
  }

  /* -------------------------------------------- */

  /**
   * Construct a manager for an item that needs to be deleted.
   * @param {Actor5e} actor         Actor from which the item should be deleted.
   * @param {string} itemId         ID of the item to be deleted.
   * @param {object} [options={}]   Rendering options passed to the application.
   * @returns {AdvancementManager}  Prepared manager. Steps count can be used to determine if advancements are needed.
   */
  static forDeletedItem(actor, itemId, options={}) {
    const manager = new this(actor, options);
    const clonedItem = manager.clone.items.get(itemId);
    if ( !clonedItem ) return manager;

    // For class items, prepare level change data
    if ( clonedItem.type === "class" ) {
      return manager.createLevelChangeSteps(clonedItem, clonedItem.system.levels * -1);
    }

    // All other items, just create some flows down from current character level
    Array.fromRange((manager.clone.system.details.level ?? 0) + 1)
      .flatMap(l => this.flowsForLevel(clonedItem, l))
      .reverse()
      .forEach(flow => manager.steps.push({ type: "reverse", flow, automatic: true }));

    // Add a final step to remove the item only if there are advancements to apply
    if ( manager.steps.length ) manager.steps.push({ type: "delete", item: clonedItem, automatic: true });
    return manager;
  }

  /* -------------------------------------------- */

  /**
   * Construct a manager for a change in a class's levels.
   * @param {Actor5e} actor         Actor whose level has changed.
   * @param {string} classId        ID of the class being changed.
   * @param {number} levelDelta     Levels by which to increase or decrease the class.
   * @param {object} options        Rendering options passed to the application.
   * @returns {AdvancementManager}  Prepared manager. Steps count can be used to determine if advancements are needed.
   */
  static forLevelChange(actor, classId, levelDelta, options={}) {
    const manager = new this(actor, options);
    const clonedItem = manager.clone.items.get(classId);
    if ( !clonedItem ) return manager;
    return manager.createLevelChangeSteps(clonedItem, levelDelta);
  }

  /* -------------------------------------------- */

  /**
   * Create steps based on the provided level change data.
   * @param {string} classItem      Class being changed.
   * @param {number} levelDelta     Levels by which to increase or decrease the class.
   * @returns {AdvancementManager}  Manager with new steps.
   */
  createLevelChangeSteps(classItem, levelDelta) {
    const raceItem = this.clone.system?.details?.race instanceof Item ? this.clone.system.details.race : null;
    const pushSteps = (flows, data) => this.steps.push(...flows.map(flow => ({ flow, ...data })));
    const getItemFlows = characterLevel => this.clone.items.contents.flatMap(i => {
      if ( ["class", "subclass", "race"].includes(i.type) ) return [];
      return this.constructor.flowsForLevel(i, characterLevel);
    });

    // Level increased
    for ( let offset = 1; offset <= levelDelta; offset++ ) {
      const classLevel = classItem.system.levels + offset;
      const characterLevel = (this.actor.system.details.level ?? 0) + offset;
      const stepData = { type: "forward", class: {item: classItem, level: classLevel} };
      pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem, classLevel), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel), stepData);
      pushSteps(getItemFlows(characterLevel), stepData);
    }

    // Level decreased
    for ( let offset = 0; offset > levelDelta; offset-- ) {
      const classLevel = classItem.system.levels + offset;
      const characterLevel = (this.actor.system.details.level ?? 0) + offset;
      const stepData = { type: "reverse", class: {item: classItem, level: classLevel}, automatic: true };
      pushSteps(getItemFlows(characterLevel).reverse(), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem.subclass, classLevel).reverse(), stepData);
      pushSteps(this.constructor.flowsForLevel(classItem, classLevel).reverse(), stepData);
      pushSteps(this.constructor.flowsForLevel(raceItem, characterLevel).reverse(), stepData);
      if ( classLevel === 1 ) this.steps.push({ type: "delete", item: classItem, automatic: true });
    }

    // Ensure the class level ends up at the appropriate point
    this.steps.push({
      type: "forward", automatic: true,
      class: { item: classItem, level: classItem.system.levels += levelDelta }
    });

    return this;
  }

  /* -------------------------------------------- */

  /**
   * Creates advancement flows for all advancements at a specific level.
   * @param {Item5e} item                               Item that has advancement.
   * @param {number} level                              Level in question.
   * @param {object} [options={}]
   * @param {AdvancementStep[]} [options.findExisting]  Find if an existing matching flow exists.
   * @returns {AdvancementFlow[]}                       Created or matched flow applications.
   */
  static flowsForLevel(item, level, { findExisting }={}) {
    const match = (advancement, step) => (step.flow?.item.id === item.id)
      && (step.flow?.advancement.id === advancement.id)
      && (step.flow?.level === level);
    return (item?.advancement.byLevel[level] ?? [])
      .filter(a => a.appliesToClass)
      .map(a => {
        const existing = findExisting?.find(s => match(a, s))?.flow;
        if ( !existing ) return new a.constructor.metadata.apps.flow(item, a.id, level);
        existing.item = item;
        return existing;
      });
  }

  /* -------------------------------------------- */

  /**
   * Determine the proper working level either from the provided item or from the cloned actor.
   * @param {Item5e} item    Item being advanced. If class or subclass, its level will be used.
   * @param {Actor5e} actor  Actor being advanced.
   * @returns {number}       Working level.
   */
  static currentLevel(item, actor) {
    return item.system.levels ?? item.class?.system.levels ?? actor.system.details.level ?? 0;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    options.window ??= {};
    options.window.subtitle ??= this.subtitle;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    if ( !this.step ) return context;

    const visibleSteps = this.steps.filter(s => !s.automatic);
    const visibleIndex = visibleSteps.indexOf(this.step);

    return {
      ...context,
      actor: this.clone,
      // Keep styles from non-converted flow applications functioning
      // Should be removed when V1 of `AdvancementFlow` is deprecated
      flowClasses: this.step.flow instanceof Application ? "dnd5e advancement flow" : "",
      flowId: this.step.flow.id,
      steps: {
        current: visibleIndex + 1,
        total: visibleSteps.length,
        hasPrevious: visibleIndex > 0,
        hasNext: visibleIndex < visibleSteps.length - 1
      }
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  render(forced=false, options={}) {
    if ( this.steps.length && (this.#stepIndex === null) ) this.#stepIndex = 0;

    // Ensure the level on the class item matches the specified level
    if ( this.step?.class ) {
      let level = this.step.class.level;
      if ( this.step.type === "reverse" ) level -= 1;
      this.step.class.item.updateSource({"system.levels": level});
      this.clone.reset();
    }

    /**
     * A hook event that fires when an AdvancementManager is about to be processed.
     * @function dnd5e.preAdvancementManagerRender
     * @memberof hookEvents
     * @param {AdvancementManager} advancementManager The advancement manager about to be rendered
     */
    const allowed = Hooks.call("dnd5e.preAdvancementManagerRender", this);

    // Abort if not allowed
    if ( allowed === false ) return this;

    const automaticData = (this.options.automaticApplication && (options.direction !== "backward"))
      ? this.step?.flow?.getAutomaticApplicationValue() : false;

    if ( this.step?.automatic || (automaticData !== false) ) {
      if ( this.#advancing ) return this;
      this.#forward({ automaticData });
      return this;
    }

    return super.render(forced, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    super._onRender(context, options);
    if ( !this.rendered || !this.step ) return;
    this.#visualizer?.render({ force: true });

    // Render the step
    this.step.flow._element = null;
    this.step.flow.options.manager ??= this;
    await this.step.flow._render(true, options);
    this.setPosition();
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options={}) {
    if ( !options.skipConfirmation ) {
      return new Dialog({
        title: `${game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Title")}: ${this.actor.name}`,
        content: game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Message"),
        buttons: {
          close: {
            icon: '<i class="fas fa-times" inert></i>',
            label: game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Action.Stop"),
            callback: () => {
              this.#visualizer?.close();
              super.close(options);
            }
          },
          continue: {
            icon: '<i class="fas fa-chevron-right" inert></i>',
            label: game.i18n.localize("DND5E.ADVANCEMENT.Manager.ClosePrompt.Action.Continue")
          }
        },
        default: "close"
      }).render(true);
    }
    this.#visualizer?.close();
    await super.close(options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle one of the buttons for moving through the process.
   * @this {AdvancementManager}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #process(event, target) {
    target.disabled = true;
    this.element.querySelector(".error")?.classList.remove("error");
    try {
      switch ( target.dataset.action ) {
        case "restart":
          if ( this.previousStep ) await this.#restart(event);
          break;
        case "previous":
          if ( this.previousStep ) await this.#backward(event);
          break;
        case "next":
        case "complete":
          await this.#forward(event);
          break;
      }
    } finally {
      target.disabled = false;
    }
  }

  /* -------------------------------------------- */
  /*  Process                                     */
  /* -------------------------------------------- */

  /**
   * Advance through the steps until one requiring user interaction is encountered.
   * @param {object} config
   * @param {object} [config.automaticData]  Data provided to handle automatic application.
   * @param {Event} [config.event]           Triggering click event if one occurred.
   * @returns {Promise}
   */
  async #forward({ automaticData, event }) {
    this.#advancing = true;
    try {
      do {
        const flow = this.step.flow;
        const type = this.step.type;
        const preEmbeddedItems = Array.from(this.clone.items);

        // Apply changes based on step type
        if ( (type === "delete") && this.step.item ) {
          if ( this.step.flow?.retainedData?.retainedItems ) {
            this.step.flow.retainedData.retainedItems[this.step.item.flags.dnd5e?.sourceId] = this.step.item.toObject();
          }
          this.clone.items.delete(this.step.item.id);
        } else if ( (type === "delete") && this.step.advancement ) {
          this.step.advancement.item.deleteAdvancement(this.step.advancement.id, { source: true });
        }
        else if ( type === "restore" ) await flow.advancement.restore(flow.level, flow.retainedData);
        else if ( type === "reverse" ) await flow.retainData(await flow.advancement.reverse(flow.level));
        else if ( automaticData && flow ) await flow.advancement.apply(flow.level, automaticData);
        else if ( flow ) await flow._updateObject(event, flow._getSubmitData());

        this.#synthesizeSteps(preEmbeddedItems);
        this.#stepIndex++;

        // Ensure the level on the class item matches the specified level
        if ( this.step?.class ) {
          let level = this.step.class.level;
          if ( this.step.type === "reverse" ) level -= 1;
          this.step.class.item.updateSource({"system.levels": level});
        }
        this.clone.reset();
      } while ( this.step?.automatic );
    } catch(error) {
      if ( !(error instanceof Advancement.ERROR) ) throw error;
      ui.notifications.error(error.message);
      this.step.automatic = false;
      if ( this.step.type === "restore" ) this.step.type = "forward";
    } finally {
      this.#advancing = false;
    }

    if ( this.step ) this.render({ force: true, direction: "forward" });
    else this.#complete();
  }

  /* -------------------------------------------- */

  /**
   * Add synthetic steps for any added or removed items with advancement.
   * @param {Item5e[]} preEmbeddedItems  Items present before the current step was applied.
   */
  #synthesizeSteps(preEmbeddedItems) {
    // Build a set of item IDs for non-synthetic steps
    const initialIds = this.steps.reduce((ids, step) => {
      if ( step.synthetic || !step.flow?.item ) return ids;
      ids.add(step.flow.item.id);
      return ids;
    }, new Set());

    const preIds = new Set(preEmbeddedItems.map(i => i.id));
    const postIds = new Set(this.clone.items.map(i => i.id));
    const addedIds = postIds.difference(preIds).difference(initialIds);
    const deletedIds = preIds.difference(postIds).difference(initialIds);

    for ( const addedId of addedIds ) {
      const item = this.clone.items.get(addedId);
      if ( !item.hasAdvancement ) continue;

      let handledLevel = 0;
      for ( let idx = this.#stepIndex; idx < this.steps.length; idx++ ) {
        // Find spots where the level increases
        const thisLevel = this.steps[idx].flow?.level ?? this.steps[idx].class?.level;
        const nextLevel = this.steps[idx + 1]?.flow?.level ?? this.steps[idx + 1]?.class?.level;
        if ( (thisLevel < handledLevel) || (thisLevel === nextLevel) ) continue;

        // Determine if there is any advancement to be done for the added item to this level
        // from the previously handled level
        const steps = Array.fromRange(thisLevel - handledLevel + 1, handledLevel)
          .flatMap(l => this.constructor.flowsForLevel(item, l, { findExisting: this.steps }))
          .map(flow => ({ type: "forward", flow, synthetic: true }));

        // Add new steps at the end of the level group
        this.steps.splice(idx + 1, 0, ...steps);
        idx += steps.length;

        handledLevel = nextLevel ?? handledLevel;
      }
    }

    if ( (this.step.type === "delete") && this.step.synthetic ) return;
    for ( const deletedId of deletedIds ) {
      let item = preEmbeddedItems.find(i => i.id === deletedId);
      if ( !item?.hasAdvancement ) continue;

      // Temporarily add the item back
      this.clone.updateSource({items: [item.toObject()]});
      item = this.clone.items.get(item.id);

      // Check for advancement from the maximum level handled by this manager to zero
      let steps = [];
      Array.fromRange(this.clone.system.details.level + 1)
        .flatMap(l => this.constructor.flowsForLevel(item, l))
        .reverse()
        .forEach(flow => steps.push({ type: "reverse", flow, automatic: true, synthetic: true }));

      // Add a new remove item step to the end of the synthetic steps to finally get rid of this item
      steps.push({ type: "delete", flow: this.step.flow, item, automatic: true, synthetic: true });

      // Add new steps after the current step
      this.steps.splice(this.#stepIndex + 1, 0, ...steps);
    }
  }

  /* -------------------------------------------- */

  /**
   * Reverse through the steps until one requiring user interaction is encountered.
   * @param {Event} [event]                  Triggering click event if one occurred.
   * @param {object} [options]               Additional options to configure behavior.
   * @param {boolean} [options.render=true]  Whether to render the Application after the step has been reversed. Used
   *                                         by the restart workflow.
   * @returns {Promise}
   */
  async #backward(event, { render=true }={}) {
    this.#advancing = true;
    try {
      do {
        this.#stepIndex--;
        if ( !this.step ) break;
        const flow = this.step.flow;
        const type = this.step.type;
        const preEmbeddedItems = Array.from(this.clone.items);

        // Reverse step based on step type
        if ( (type === "delete") && this.step.item ) this.clone.updateSource({items: [this.step.item]});
        else if ( (type === "delete") && this.step.advancement ) this.advancement.item.createAdvancement(
          this.advancement.typeName, this.advancement._source, { source: true }
        );
        else if ( type === "reverse" ) await flow.advancement.restore(flow.level, flow.retainedData);
        else if ( flow ) await flow.retainData(await flow.advancement.reverse(flow.level));

        this.#clearSyntheticSteps(preEmbeddedItems);
        this.clone.reset();
      } while ( this.step?.automatic );
    } catch(error) {
      if ( !(error instanceof Advancement.ERROR) ) throw error;
      ui.notifications.error(error.message);
      this.step.automatic = false;
    } finally {
      this.#advancing = false;
    }

    if ( !render ) return;
    if ( this.step ) this.render(true, { direction: "backward" });
    else this.close({ skipConfirmation: true });
  }

  /* -------------------------------------------- */

  /**
   * Remove synthetic steps for any added or removed items.
   * @param {Item5e[]} preEmbeddedItems  Items present before the current step was applied.
   */
  #clearSyntheticSteps(preEmbeddedItems) {
    // Create a disjoint union of the before and after items
    const preIds = new Set(preEmbeddedItems.map(i => i.id));
    const postIds = new Set(this.clone.items.map(i => i.id));
    const modifiedIds = postIds.symmetricDifference(preIds);

    // Remove any synthetic steps after the current step if their item has been modified
    for ( const [idx, element] of Array.from(this.steps.entries()).reverse() ) {
      if ( idx <= this.#stepIndex ) break;
      if ( element.synthetic && modifiedIds.has(element.flow?.item?.id) ) this.steps.splice(idx, 1);
    }
  }

  /* -------------------------------------------- */

  /**
   * Reset back to the manager's initial state.
   * @param {MouseEvent} [event]  The triggering click event if one occurred.
   * @returns {Promise}
   */
  async #restart(event) {
    const restart = await Dialog.confirm({
      title: game.i18n.localize("DND5E.ADVANCEMENT.Manager.RestartPrompt.Title"),
      content: game.i18n.localize("DND5E.ADVANCEMENT.Manager.RestartPrompt.Message")
    });
    if ( !restart ) return;
    // While there is still a renderable step.
    while ( this.steps.slice(0, this.#stepIndex).some(s => !s.automatic) ) {
      await this.#backward(event, {render: false});
    }
    this.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Apply changes to actual actor after all choices have been made.
   * @param {Event} event  Button click that triggered the change.
   * @returns {Promise}
   */
  async #complete(event) {
    const updates = this.clone.toObject();
    const items = updates.items;
    delete updates.items;

    // Gather changes to embedded items
    const { toCreate, toUpdate, toDelete } = items.reduce((obj, item) => {
      if ( !this.actor.items.get(item._id) ) {
        obj.toCreate.push(item);
      } else {
        obj.toUpdate.push(item);
        obj.toDelete.findSplice(id => id === item._id);
      }
      return obj;
    }, { toCreate: [], toUpdate: [], toDelete: this.actor.items.map(i => i.id) });

    /**
     * A hook event that fires at the final stage of a character's advancement process, before actor and item updates
     * are applied.
     * @function dnd5e.preAdvancementManagerComplete
     * @memberof hookEvents
     * @param {AdvancementManager} advancementManager  The advancement manager.
     * @param {object} actorUpdates                    Updates to the actor.
     * @param {object[]} toCreate                      Items that will be created on the actor.
     * @param {object[]} toUpdate                      Items that will be updated on the actor.
     * @param {string[]} toDelete                      IDs of items that will be deleted on the actor.
     */
    if ( Hooks.call("dnd5e.preAdvancementManagerComplete", this, updates, toCreate, toUpdate, toDelete) === false ) {
      log("AdvancementManager completion was prevented by the 'preAdvancementManagerComplete' hook.");
      return this.close({ skipConfirmation: true });
    }

    // Apply changes from clone to original actor
    await Promise.all([
      this.actor.update(updates, { isAdvancement: true }),
      this.actor.createEmbeddedDocuments("Item", toCreate, { keepId: true, isAdvancement: true }),
      this.actor.updateEmbeddedDocuments("Item", toUpdate, { isAdvancement: true }),
      this.actor.deleteEmbeddedDocuments("Item", toDelete, { isAdvancement: true })
    ]);

    /**
     * A hook event that fires when an AdvancementManager is done modifying an actor.
     * @function dnd5e.advancementManagerComplete
     * @memberof hookEvents
     * @param {AdvancementManager} advancementManager The advancement manager that just completed
     */
    Hooks.callAll("dnd5e.advancementManagerComplete", this);

    // Close prompt
    return this.close({ skipConfirmation: true });
  }
}

/* -------------------------------------------- */

/**
 * Debug application for visualizing advancement steps.
 * Note: Intentionally not localized due to its nature as a debug application.
 */
class AdvancementVisualizer extends Application5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["advancement-visualizer"],
    window: {
      title: "Advancement Steps"
    },
    position: {
      top: 50,
      left: 50,
      width: 440
    },
    manager: null
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    steps: {
      template: "systems/dnd5e/templates/advancement/advancement-visualizer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The advancement manager that this is visualizing.
   * @type {AdvancementManager}
   */
  get manager() {
    return this.options.manager;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.steps = this.manager.steps.map(step => ({
      ...step,
      current: step === this.manager.step
    }));
    return context;
  }
}

/**
 * Dialog to confirm the deletion of an embedded item with advancement or decreasing a class level.
 */
class AdvancementConfirmationDialog extends Dialog {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/advancement-confirmation-dialog.hbs",
      jQuery: false
    });
  }

  /* -------------------------------------------- */

  /**
   * A helper function that displays the dialog prompting for an item deletion.
   * @param {Item5e} item  Item to be deleted.
   * @returns {Promise<boolean|null>}  Resolves with whether advancements should be unapplied. Rejects with null.
   */
  static forDelete(item) {
    return this.createDialog(
      item,
      game.i18n.localize("DND5E.AdvancementDeleteConfirmationTitle"),
      game.i18n.localize("DND5E.AdvancementDeleteConfirmationMessage"),
      {
        icon: '<i class="fas fa-trash"></i>',
        label: game.i18n.localize("Delete")
      }
    );
  }

  /* -------------------------------------------- */

  /**
   * A helper function that displays the dialog prompting for leveling down.
   * @param {Item5e} item  The class whose level is being changed.
   * @returns {Promise<boolean|null>}  Resolves with whether advancements should be unapplied. Rejects with null.
   */
  static forLevelDown(item) {
    return this.createDialog(
      item,
      game.i18n.localize("DND5E.AdvancementLevelDownConfirmationTitle"),
      game.i18n.localize("DND5E.AdvancementLevelDownConfirmationMessage"),
      {
        icon: '<i class="fas fa-sort-numeric-down-alt"></i>',
        label: game.i18n.localize("DND5E.LevelActionDecrease")
      }
    );
  }

  /* -------------------------------------------- */

  /**
   * A helper constructor function which displays the confirmation dialog.
   * @param {Item5e} item              Item to be changed.
   * @param {string} title             Localized dialog title.
   * @param {string} message           Localized dialog message.
   * @param {object} continueButton    Object containing label and icon for the action button.
   * @returns {Promise<boolean|null>}  Resolves with whether advancements should be unapplied. Rejects with null.
   */
  static createDialog(item, title, message, continueButton) {
    return new Promise((resolve, reject) => {
      const dialog = new this({
        title: `${title}: ${item.name}`,
        content: message,
        buttons: {
          continue: foundry.utils.mergeObject(continueButton, {
            callback: html => {
              const checkbox = html.querySelector('input[name="apply-advancement"]');
              resolve(checkbox.checked);
            }
          }),
          cancel: {
            icon: '<i class="fas fa-times"></i>',
            label: game.i18n.localize("Cancel"),
            callback: html => reject(null)
          }
        },
        default: "continue",
        close: () => reject(null)
      });
      dialog.render(true);
    });
  }

}

const { NumberField: NumberField$x, StringField: StringField$O } = foundry.data.fields;

/**
 * Application for configuration spell scroll creation.
 */
class CreateScrollDialog extends Dialog5e {
  constructor(options={}) {
    super(options);
    this.#config = options.config;
    this.#spell = options.spell;
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["create-scroll"],
    window: {
      title: "DND5E.Scroll.CreateScroll",
      icon: "fa-solid fa-scroll"
    },
    form: {
      handler: CreateScrollDialog.#handleFormSubmission
    },
    position: {
      width: 420
    },
    buttons: [{
      action: "create",
      label: "DND5E.Scroll.CreateScroll",
      icon: "fa-solid fa-check",
      default: true
    }],
    config: null,
    spell: null
  };

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/apps/spell-scroll-dialog.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Configuration options for scroll creation.
   * @type {SpellScrollConfiguration}
   */
  #config;

  get config() {
    return this.#config;
  }

  /* -------------------------------------------- */

  /**
   * Spell from which the scroll will be created.
   * @type {Item5e|object}
   */
  #spell;

  get spell() {
    return this.#spell;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Prepare rendering context for the content section.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @param {HandlebarsRenderOptions} options   Options which configure application rendering behavior.
   * @returns {Promise<ApplicationRenderContext>}
   * @protected
   */
  async _prepareContentContext(context, options) {
    context.anchor = this.spell instanceof Item ? this.spell.toAnchor().outerHTML : `<span>${this.spell.name}</span>`;
    context.config = this.config;
    context.fields = [{
      field: new StringField$O({
        label: game.i18n.localize("DND5E.Scroll.Explanation.Label"),
        hint: game.i18n.localize("DND5E.Scroll.Explanation.Hint")
      }),
      name: "explanation",
      options: [
        { value: "full", label: game.i18n.localize("DND5E.Scroll.Explanation.Complete") },
        { value: "reference", label: game.i18n.localize("DND5E.Scroll.Explanation.Reference") },
        { value: "none", label: game.i18n.localize("DND5E.None") }
      ],
      value: this.config.explanation ?? "reference"
    }, {
      field: new NumberField$x({ label: game.i18n.localize("DND5E.SpellLevel") }),
      name: "level",
      options: Object.entries(CONFIG.DND5E.spellLevels)
        .map(([value, label]) => ({ value, label }))
        .filter(l => Number(l.value) >= this.spell.system.level),
      value: this.config.level ?? this.spell.system.level
    }];
    context.values = {
      bonus: new NumberField$x({ label: game.i18n.localize("DND5E.BonusAttack") }),
      dc: new NumberField$x({ label: game.i18n.localize("DND5E.Scroll.SaveDC") })
    };
    context.valuePlaceholders = {};
    for ( const level of Array.fromRange(this.config.level + 1).reverse() ) {
      context.valuePlaceholders = CONFIG.DND5E.spellScrollValues[level];
      if ( context.valuePlaceholders ) break;
    }
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle submission of the dialog using the form buttons.
   * @this {CreateScrollDialog}
   * @param {Event|SubmitEvent} event    The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   */
  static async #handleFormSubmission(event, form, formData) {
    foundry.utils.mergeObject(this.#config, formData.object);
    this.#config.level = Number(this.#config.level);
    await this.close({ dnd5e: { submitted: true } });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const formData = new FormDataExtended(this.form);
    foundry.utils.mergeObject(this.#config, formData.object);
    this.#config.level = Number(this.#config.level);
    this.render({ parts: ["content"] });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClose(options={}) {
    if ( !options.dnd5e?.submitted ) this.#config = null;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Display the create spell scroll dialog.
   * @param {Item5e|object} spell              The spell or item data to be made into a scroll.
   * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
   * @param {object} [options={}]              Additional options for the application.
   * @returns {Promise<object|null>}           Form data object with results of the dialog.
   */
  static async create(spell, config, options={}) {
    return new Promise(resolve => {
      const dialog = new this({ spell, config, ...options });
      dialog.addEventListener("close", event => resolve(dialog.config), { once: true });
      dialog.render({ force: true });
    });
  }
}

const { BooleanField: BooleanField$s, StringField: StringField$N } = foundry.data.fields;

/**
 * Configuration application for traits.
 */
class TraitConfig extends AdvancementConfig$1 {
  constructor(...args) {
    super(...args);
    this.selected = (this.config.choices.length && !this.config.grants.size) ? 0 : -1;
    this.trait = this.types.first() ?? "skills";
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["trait", "trait-selector"],
    actions: {
      addChoice: TraitConfig.#addChoice,
      removeChoice: TraitConfig.#removeChoice
    },
    position: {
      width: 680
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    config: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    },
    details: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/trait-config-details.hbs"
    },
    guaranteed: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/trait-config-guaranteed.hbs"
    },
    choices: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/trait-config-choices.hbs"
    },
    traits: {
      container: { classes: ["column-container"], id: "column-right" },
      template: "systems/dnd5e/templates/advancement/trait-config-traits.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Shortcut to the configuration data on the advancement.
   * @type {object}
   */
  get config() {
    return this.advancement.configuration;
  }

  /* -------------------------------------------- */

  /**
   * Index of the selected configuration, `-1` means `grants` array, any other number is equal
   * to an index in `choices` array.
   * @type {number}
   */
  selected;

  /* -------------------------------------------- */

  /**
   * Trait type to display in the selector interface.
   * @type {string}
   */
  trait;

  /* -------------------------------------------- */

  /**
   * List of trait types for the current selected configuration.
   * @type {Set<string>}
   */
  get types() {
    const pool = this.selected === -1 ? this.config.grants : this.config.choices[this.selected].pool;
    return this.advancement.representedTraits([pool]);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.grants = {
      label: localizedList({ grants: this.config.grants }) || "—",
      data: this.config.grants,
      selected: this.selected === -1
    };
    context.choices = this.config.choices.map((choice, index) => ({
      label: choiceLabel(choice, { only: true }).capitalize() || "—",
      data: choice,
      selected: this.selected === index
    }));
    const chosen = (this.selected === -1) ? context.grants.data : context.choices[this.selected].data.pool;
    if ( this.selected !== -1 ) context.count = {
      field: context.configuration.fields.choices.element.fields.count,
      value: context.choices[this.selected]?.data.count
    };
    context.selectedIndex = this.selected;

    const rep = this.advancement.representedTraits();
    context.disableAllowReplacements = rep.size > 1;
    const traitConfig = rep.size === 1 ? CONFIG.DND5E.traits[rep.first()] : null;
    if ( traitConfig ) {
      context.default.title = traitConfig.labels.title;
      context.default.icon = traitConfig.icon;
    } else {
      context.default.title = game.i18n.localize("DND5E.TraitGenericPlural.other");
      context.default.icon = this.advancement.constructor.metadata.icon;
    }
    context.default.hint = localizedList({ grants: this.config.grants, choices: this.config.choices });

    context.trait = {
      field: new BooleanField$s(),
      input: context.inputs.createCheckboxInput,
      options: await choices(this.trait, { chosen, prefixed: true, any: this.selected !== -1 }),
      selected: this.trait,
      selectedHeader: `${CONFIG.DND5E.traits[this.trait].labels.localization}.other`,
      typeField: new StringField$N({ label: game.i18n.localize("DND5E.ADVANCEMENT.Trait.TraitType") }),
      typeOptions: Object.entries(CONFIG.DND5E.traits)
        .filter(([, config]) => ((this.config.mode === "default") || (this.config.mode === "mastery"
          ? config.mastery : config.expertise)) && (config.dataType !== Number))
        .map(([value, config]) => ({ value, label: config.labels.title }))
    };

    // Disable selecting categories in mastery mode
    if ( this.advancement.configuration.mode === "mastery" ) {
      context.trait.options.forEach((key, value) => value.disabled = !!value.children);
    }

    context.mode = {
      hint: CONFIG.DND5E.traitModes[this.advancement.configuration.mode].hint,
      options: Object.entries(CONFIG.DND5E.traitModes).map(([value, { label }]) => ({ value, label }))
    };

    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    // Handle selecting & disabling category children when a category is selected
    for ( const checkbox of this.element.querySelectorAll(".trait-list dnd5e-checkbox[checked]") ) {
      const toCheck = (checkbox.name.endsWith("*") || checkbox.name.endsWith("ALL"))
        ? checkbox.closest("ol").querySelectorAll(`dnd5e-checkbox:not([name="${checkbox.name}"])`)
        : checkbox.closest("li").querySelector("ol")?.querySelectorAll("dnd5e-checkbox");
      toCheck?.forEach(i => i.checked = i.disabled = true);
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle adding a new choice.
   * @this {TraitConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #addChoice(event, target) {
    this.config.choices.push({ count: 1 });
    this.selected = this.config.choices.length - 1;
    await this.advancement.update({ configuration: await this.prepareConfigurationUpdate() });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a choice.
   * @this {TraitConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #removeChoice(event, target) {
    const input = target.closest("li").querySelector("[name='selectedIndex']");
    const selectedIndex = Number(input.value);
    this.config.choices.splice(selectedIndex, 1);
    if ( selectedIndex <= this.selected ) this.selected -= 1;
    await this.advancement.update({ configuration: await this.prepareConfigurationUpdate() });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    // Display new set of trait choices
    if ( event.target.name === "selectedTrait" ) {
      this.trait = event.target.value;
      return this.render();
    }

    // Change selected configuration set
    else if ( event.target.name === "selectedIndex" ) {
      this.selected = Number(event.target.value ?? -1);
      const types = this.types;
      if ( types.size && !types.has(this.trait) ) this.trait = types.first();
      return this.render();
    }

    // If mode is changed from default to one of the others, change selected type if current type is not valid
    if ( (event.target.name === "configuration.mode")
      && (event.target.value !== "default")
      && (event.target.value !== this.config.mode) ) {
      const checkKey = event.target.value === "mastery" ? "mastery" : "expertise";
      const validTraitTypes = filteredKeys(CONFIG.DND5E.traits, c => c[checkKey]);
      if ( !validTraitTypes.includes(this.trait) ) this.trait = validTraitTypes[0];
    }

    super._onChangeForm(formConfig, event);
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async prepareConfigurationUpdate(configuration={}) {
    const choicesCollection = foundry.utils.deepClone(this.config.choices);

    if ( configuration.checked ) {
      const prefix = `${this.trait}:`;
      const filteredSelected = filteredKeys(configuration.checked);

      // Update grants
      if ( this.selected === -1 ) {
        const filteredPrevious = this.config.grants.filter(k => !k.startsWith(prefix));
        configuration.grants = [...filteredPrevious, ...filteredSelected];
      }

      // Update current choice pool
      else {
        const current = choicesCollection[this.selected];
        const filteredPrevious = current.pool.filter(k => !k.startsWith(prefix));
        current.pool = [...filteredPrevious, ...filteredSelected];
      }
      delete configuration.checked;
    }

    if ( configuration.count ) {
      choicesCollection[this.selected].count = configuration.count;
      delete configuration.count;
    }

    // TODO: Remove when https://github.com/foundryvtt/foundryvtt/issues/7706 is resolved
    choicesCollection.forEach(c => {
      if ( !c.pool ) return;
      c.pool = Array.from(c.pool);
    });
    configuration.choices = choicesCollection;
    configuration.grants ??= Array.from(this.config.grants);

    // If one of the expertise modes is selected, filter out any traits that are not of a valid type
    if ( (configuration.mode ?? this.config.mode) !== "default" ) {
      const checkKey = (configuration.mode ?? this.config.mode) === "mastery" ? "mastery" : "expertise";
      const validTraitTypes = filteredKeys(CONFIG.DND5E.traits, c => c[checkKey]);
      configuration.grants = configuration.grants.filter(k => validTraitTypes.some(t => k.startsWith(t)));
      configuration.choices.forEach(c => c.pool = c.pool?.filter(k => validTraitTypes.some(t => k.startsWith(t))));
    }

    return configuration;
  }
}

/**
 * Inline application that presents the player with a trait choices.
 */
class TraitFlow extends AdvancementFlow {

  /**
   * Array of trait keys currently chosen.
   * @type {Set<string>}
   */
  chosen;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/trait-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /**
   * Trait configuration from `CONFIG.TRAITS` for this advancement's trait type.
   * @type {TraitConfiguration}
   */
  get traitConfig() {
    return CONFIG.DND5E.traits[this.advancement.configuration.type];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData() {
    this.chosen ??= await this.prepareInitialValue();
    const available = await this.advancement.availableChoices(this.chosen);
    return foundry.utils.mergeObject(super.getData(), {
      hint: this.advancement.hint ? this.advancement.hint : localizedList({
        grants: this.advancement.configuration.grants, choices: this.advancement.configuration.choices
      }),
      slots: this.prepareTraitSlots(available),
      available
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    this.form.querySelectorAll("select").forEach(s => s.addEventListener("change", this._onSelectTrait.bind(this)));
    this.form.querySelectorAll(".remove").forEach(s => s.addEventListener("click", this._onRemoveTrait.bind(this)));
  }

  /* -------------------------------------------- */

  /**
   * Add a trait to the value when one is selected.
   * @param {Event} event  Triggering change to a select input.
   */
  _onSelectTrait(event) {
    const addedTrait = event.target.value;
    if ( addedTrait === "" ) return;
    this.chosen.add(addedTrait);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Remove a trait for the value when the remove button is clicked.
   * @param {Event} event  Triggering click.
   */
  _onRemoveTrait(event) {
    const tag = event.target.closest(".trait-slot");
    this.chosen.delete(tag.dataset.key);
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    if ( formData.chosen && !Array.isArray(formData.chosen) ) formData.chosen = [formData.chosen];
    super._updateObject(event, formData);
  }

  /* -------------------------------------------- */
  /*  Data Preparation Methods                    */
  /* -------------------------------------------- */

  /**
   * When only a single choice is available, automatically select that choice provided value is empty.
   * @returns {Set<string>}
   */
  async prepareInitialValue() {
    const existingChosen = this.retainedData?.chosen ?? this.advancement.value.chosen;
    if ( existingChosen?.size ) return new Set(existingChosen);
    const { available } = await this.advancement.unfulfilledChoices();
    const chosen = new Set();
    for ( const { choices } of available ) {
      const set = choices.asSet();
      if ( set.size === 1 ) chosen.add(set.first());
    }
    return chosen;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the list of slots to be populated by traits.
   * @param {object} available  Trait availability returned by `prepareAvailableTraits`.
   * @returns {object[]}
   */
  prepareTraitSlots(available) {
    const config = this.advancement.configuration;
    const count = config.choices.reduce((count, c) => count + c.count, config.grants.size);
    const chosen = Array.from(this.chosen);
    let selectorShown = false;
    const slots = [];
    for ( let i = 1; i <= count; i++ ) {
      const key = chosen.shift();
      if ( selectorShown || (!key && !available) ) continue;
      selectorShown = !key;
      slots.push({
        key,
        label: key ? keyLabel(key, { type: config.type }) : null,
        showDelete: !this.advancement.configuration.grants.has(key),
        showSelector: !key
      });
    }
    return slots;
  }
}

const { ArrayField: ArrayField$g, BooleanField: BooleanField$r, NumberField: NumberField$w, SetField: SetField$n, SchemaField: SchemaField$B, StringField: StringField$M } = foundry.data.fields;

/**
 * Map language category changes.
 * @type {Record<string, string>}
 */
const _MAP = {
  "languages:exotic:draconic": "languages:standard:draconic",
  "languages:cant": "languages:exotic:cant",
  "languages:druidic": "languages:exotic:druidic"
};

const LANGUAGE_MAP = { modern: _MAP, legacy: foundry.utils.invertObject(_MAP) };

/**
 * Configuration for a specific trait choice.
 *
 * @typedef {object} TraitChoice
 * @property {number} count     Number of traits that can be selected.
 * @property {string[]} [pool]  List of trait or category keys that can be chosen. If no choices are provided,
 *                              any trait of the specified type can be selected.
 */

/**
 * Configuration data for the TraitAdvancement.
 *
 * @property {boolean} allowReplacements  Whether all potential choices should be presented to the user if there
 *                                        are no more choices available in a more limited set.
 * @property {TraitChoice[]} choices      Choices presented to the user.
 * @property {string[]} grants            Keys for traits granted automatically.
 * @property {string} mode                Method by which this advancement modifies the actor's traits.
 */
class TraitConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.Trait"];

  /* -------------------------------------------- */

  static defineSchema() {
    return {
      allowReplacements: new BooleanField$r({ required: true }),
      choices: new ArrayField$g(new SchemaField$B({
        count: new NumberField$w({ required: true, positive: true, integer: true, initial: 1 }),
        pool: new SetField$n(new StringField$M(), { required: false })
      })),
      grants: new SetField$n(new StringField$M(), { required: true }),
      mode: new StringField$M({ required: true, blank: false, initial: "default" })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    const version = game.settings.get("dnd5e", "rulesVersion");
    const languageMap = LANGUAGE_MAP[version] ?? {};
    if ( source.grants?.length ) source.grants = source.grants.map(t => languageMap[t] ?? t);
    if ( source.choices?.length ) source.choices.forEach(c => c.pool = c.pool.map(t => languageMap[t] ?? t));
    return source;
  }
}

/**
 * Value data for the TraitAdvancement.
 *
 * @property {Set<string>} chosen  Trait keys that have been chosen.
 */
class TraitValueData extends foundry.abstract.DataModel {
  static defineSchema() {
    return {
      chosen: new SetField$n(new StringField$M(), { required: false })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    const version = game.settings.get("dnd5e", "rulesVersion");
    const languageMap = LANGUAGE_MAP[version] ?? {};
    if ( source.chosen?.length ) source.chosen = source.chosen.map(t => languageMap[t] ?? t);
    return source;
  }
}

/**
 * Advancement that grants the player with certain traits or presents them with a list of traits from which
 * to choose.
 */
class TraitAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: TraitConfigurationData,
        value: TraitValueData
      },
      order: 30,
      icon: "icons/sundries/scrolls/scroll-yellow-teal.webp",
      typeIcon: "systems/dnd5e/icons/svg/trait.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.Trait.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.Trait.Hint"),
      apps: {
        config: TraitConfig,
        flow: TraitFlow
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Perform the pre-localization of this data model.
   */
  static localize() {
    super.localize();
    localizeSchema(
      this.metadata.dataModels.configuration.schema.fields.choices.element,
      ["DND5E.ADVANCEMENT.Trait.FIELDS.choices"]
    );
  }

  /* -------------------------------------------- */

  /**
   * The maximum number of traits granted by this advancement. The number of traits actually granted may be lower if
   * actor already has some traits.
   * @type {number}
   */
  get maxTraits() {
    const { grants, choices } = this.configuration;
    return grants.size + choices.reduce((acc, choice) => acc + choice.count, 0);
  }

  /* -------------------------------------------- */
  /*  Preparation Methods                         */
  /* -------------------------------------------- */

  /**
   * Prepare data for the Advancement.
   */
  prepareData() {
    const rep = this.representedTraits();
    const traitConfig = rep.size === 1 ? CONFIG.DND5E.traits[rep.first()] : null;
    this.title = this.title || traitConfig?.labels.title || this.constructor.metadata.title;
    this.icon = this.icon || traitConfig?.icon || this.constructor.metadata.icon;
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  configuredForLevel(level) {
    return !!this.value.chosen?.size;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  sortingValueForLevel(levels) {
    const traitOrder = Object.keys(CONFIG.DND5E.traits).findIndex(k => k === this.representedTraits().first());
    const modeOrder = Object.keys(CONFIG.DND5E.traitModes).findIndex(k => k === this.configuration.mode);
    const order = traitOrder + (modeOrder * 100);
    return `${this.constructor.metadata.order.paddedString(4)} ${order.paddedString(4)} ${this.titleForLevel(levels)}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    if ( configMode ) {
      if ( this.hint ) return `<p>${this.hint}</p>`;
      return `<p>${localizedList({
        grants: this.configuration.grants, choices: this.configuration.choices
      })}</p>`;
    } else {
      return Array.from(this.value.chosen).map(k => `<span class="tag">${keyLabel(k)}</span>`).join(" ");
    }
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, data) {
    const updates = {};
    if ( !data.chosen ) return;

    for ( const key of data.chosen ) {
      const keyPath = this.configuration.mode === "mastery" ? "system.traits.weaponProf.mastery.value"
        : changeKeyPath(key);
      let existingValue = updates[keyPath] ?? foundry.utils.getProperty(this.actor, keyPath);

      if ( ["Array", "Set"].includes(foundry.utils.getType(existingValue)) ) {
        existingValue = new Set(existingValue);
        existingValue.add(key.split(":").pop());
        updates[keyPath] = Array.from(existingValue);
      } else if ( (this.configuration.mode !== "expertise") || (existingValue !== 0) ) {
        updates[keyPath] = (this.configuration.mode === "default")
          || ((this.configuration.mode === "upgrade") && (existingValue === 0)) ? 1 : 2;
      }

      if ( key.startsWith("tool") ) {
        const toolId = key.split(":").pop();
        const ability = CONFIG.DND5E.tools[toolId]?.ability;
        const kp = `system.tools.${toolId}.ability`;
        if ( ability && !foundry.utils.hasProperty(this.actor, kp) ) updates[kp] = ability;
      }
    }

    this.actor.updateSource(updates);
    this.updateSource({ "value.chosen": Array.from(data.chosen) });
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    // TODO: Ideally this would be able to detect situations where choices are automatically fulfilled because
    // they only have one valid option, but that is an async process and cannot be called from within `render`
    if ( this.configuration.choices.length || this.configuration.allowReplacements ) return false;
    return { chosen: Array.from(this.configuration.grants) };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async restore(level, data) {
    this.apply(level, data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async reverse(level) {
    const updates = {};
    if ( !this.value.chosen ) return;

    for ( const key of this.value.chosen ) {
      const keyPath = this.configuration.mode === "mastery" ? "system.traits.weaponProf.mastery.value"
        : changeKeyPath(key);
      let existingValue = updates[keyPath] ?? foundry.utils.getProperty(this.actor, keyPath);

      if ( ["Array", "Set"].includes(foundry.utils.getType(existingValue)) ) {
        existingValue = new Set(existingValue);
        existingValue.delete(key.split(":").pop());
        updates[keyPath] = Array.from(existingValue);
      }

      else if ( this.configuration.mode === "expertise" ) updates[keyPath] = 1;
      else if ( this.configuration.mode === "upgrade" ) updates[keyPath] = existingValue === 1 ? 0 : 1;
      else updates[keyPath] = 0;
      // NOTE: When using forced expertise mode, this will not return to original value
      // if the value before being applied is 1.
    }

    const retainedData = foundry.utils.deepClone(this.value);
    this.actor.updateSource(updates);
    this.updateSource({ "value.chosen": [] });
    return retainedData;
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * Two sets of keys based on actor data, one that is considered "selected" and thus unavailable to be chosen
   * and another that is "available". This is based off configured advancement mode.
   * @returns {{selected: Set<string>, available: Set<string>}}
   */
  async actorSelected() {
    const selected = new Set();
    const available = new Set();

    // If "default" mode is selected, return all traits
    // If any other mode is selected, only return traits that support expertise or mastery
    const traitTypes = this.configuration.mode === "default" ? Object.keys(CONFIG.DND5E.traits).filter(k => k !== "dm")
      : filteredKeys(CONFIG.DND5E.traits, t => t[this.configuration.mode === "mastery" ? "mastery" : "expertise"]);

    for ( const trait$1 of traitTypes ) {
      const actorValues$1 = await actorValues(this.actor, trait$1);
      const choices$1 = await choices(trait$1, { prefixed: true });
      for ( const key of choices$1.asSet() ) {
        const value = actorValues$1[key] ?? 0;
        if ( this.configuration.mode === "default" ) {
          if ( value >= 1 ) selected.add(key);
          else available.add(key);
        } else if ( this.configuration.mode === "mastery" ) {
          const split = key.split(":");
          split.pop();
          const category = split.join(":");
          if ( value === 2 ) selected.add(key);
          if ( (value === 1) || (actorValues$1[category] === 1) ) available.add(key);
        } else {
          if ( value === 2 ) selected.add(key);
          if ( (this.configuration.mode === "expertise") && (value === 1) ) available.add(key);
          else if ( (this.configuration.mode !== "expertise") && (value < 2) ) available.add(key);
        }
      }
    }

    return { selected, available };
  }

  /* -------------------------------------------- */

  /**
   * Guess the trait type from the grants & choices on this advancement.
   * @param {Set<string>[]} [pools]  Trait pools to use when figuring out the type.
   * @returns {Set<string>}
   */
  representedTraits(pools) {
    const set = new Set();
    pools ??= [this.configuration.grants, ...this.configuration.choices.map(c => c.pool)];
    for ( const pool of pools ) {
      for ( const key of pool ) {
        const [type] = key.split(":");
        set.add(type);
      }
    }
    return set;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the list of available traits from which the player can choose.
   * @param {Set<string>} [chosen]  Traits already chosen on the advancement. If not set then it will
   *                                be retrieved from advancement's value.
   * @returns {{choices: SelectChoices, label: string}|null}
   */
  async availableChoices(chosen) {
    // TODO: Still shows "Choose 1 x" even if not possible due to mode restriction
    let { available, choices } = await this.unfulfilledChoices(chosen);

    // If all traits of this type are already assigned, then nothing new can be selected
    if ( foundry.utils.isEmpty(choices) ) return null;

    // Remove any grants that have no choices remaining
    let unfilteredLength = available.length;
    available = available.filter(a => a.choices.asSet().size > 0);

    // If replacements are allowed and there are grants with zero choices from their limited set,
    // display all remaining choices as an option
    if ( this.configuration.allowReplacements && (unfilteredLength > available.length) ) {
      const rep = this.representedTraits();
      if ( rep.size === 1 ) return {
        choices: choices.filter(this.representedTraits().map(t => `${t}:*`), { inplace: false }),
        label: game.i18n.format("DND5E.ADVANCEMENT.Trait.ChoicesRemaining", {
          count: unfilteredLength,
          type: traitLabel(rep.first(), unfilteredLength)
        })
      };
      // TODO: This works well for types without categories like skills where it is primarily intended,
      // but perhaps there could be some improvements elsewhere. For example, if I have an advancement
      // that grants proficiency in the Bagpipes and allows replacements, but the character already has
      // Bagpipe proficiency. In this example this just lets them choose from any other tool proficiency
      // as their replacement, but it might make sense to only show other musical instruments unless
      // they already have proficiency in all musical instruments. Might not be worth the effort.
    }

    if ( !available.length ) return null;

    // Create a choices object featuring a union of choices from all remaining grants
    const remainingSet = new Set(available.flatMap(a => Array.from(a.choices.asSet())));
    choices.filter(remainingSet);

    const rep = this.representedTraits(available.map(a => a.choices.asSet()));
    return {
      choices,
      label: game.i18n.format("DND5E.ADVANCEMENT.Trait.ChoicesRemaining", {
        count: available.length,
        type: traitLabel(rep.size === 1 ? rep.first() : null, available.length)
      })
    };
  }

  /* -------------------------------------------- */

  /**
   * The advancement configuration is flattened into separate options for the user that are chosen step-by-step. Some
   * are automatically picked for them if they are 'grants' or if there is only one option after the character's
   * existing traits have been taken into account.
   * @typedef {object} TraitChoices
   * @property {"grant"|"choice"} type  Whether this trait is automatically granted or is chosen from some options.
   * @property {number} [choiceIdx]     An index that groups each separate choice into the groups that they originally
   *                                    came from.
   * @property {SelectChoices} choices  The available traits to pick from. Grants have only 0 or 1, depending on whether
   *                                    the character already has the granted trait.
   */

  /**
   * Determine which of the provided grants, if any, still needs to be fulfilled.
   * @param {Set<string>} [chosen]  Traits already chosen on the advancement. If not set then it will
   *                                be retrieved from advancement's value.
   * @returns {{ available: TraitChoices[], choices: SelectChoices }}
   */
  async unfulfilledChoices(chosen) {
    const actorData = await this.actorSelected();
    const selected = {
      actor: actorData.selected,
      item: chosen ?? this.value.selected ?? new Set()
    };

    // If everything has already been selected, no need to go further
    if ( this.maxTraits <= selected.item.size ) {
      return { available: [], choices: new SelectChoices() };
    }

    const available = await Promise.all([
      ...this.configuration.grants.map(async g => ({
        type: "grant",
        choices: await mixedChoices(new Set([g]))
      })),
      ...this.configuration.choices.reduce((arr, choice, index) => {
        return arr.concat(Array.fromRange(choice.count).map(async () => ({
          type: "choice",
          choiceIdx: index,
          choices: await mixedChoices(choice.pool)
        })));
      }, [])
    ]);

    available.sort((lhs, rhs) => lhs.choices.asSet().size - rhs.choices.asSet().size);

    // Remove any fulfilled grants
    for ( const key of selected.item ) available.findSplice(grant => grant.choices.asSet().has(key));

    // Merge all possible choices into a single SelectChoices
    const allChoices = await mixedChoices(actorData.available);
    allChoices.exclude(new Set([...(selected.actor ?? []), ...selected.item]));
    available.forEach(a => a.choices = allChoices.filter(a.choices, { inplace: false }));

    return { available, choices: allChoices };
  }
}

/**
 * Data field that selects the appropriate advancement data model if available, otherwise defaults to generic
 * `ObjectField` to prevent issues with custom advancement types that aren't currently loaded.
 */
class AdvancementField extends foundry.data.fields.ObjectField {

  /**
   * Get the BaseAdvancement definition for the specified advancement type.
   * @param {string} type                    The Advancement type.
   * @returns {typeof BaseAdvancement|null}  The BaseAdvancement class, or null.
   */
  getModelForType(type) {
    return CONFIG.DND5E.advancementTypes[type]?.documentClass ?? null;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _cleanType(value, options) {
    if ( !(typeof value === "object") ) value = {};

    const cls = this.getModelForType(value.type);
    if ( cls ) return cls.cleanData(value, options);
    return value;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(value, model, options={}) {
    const cls = this.getModelForType(value.type);
    if ( cls ) return new cls(value, {parent: model, ...options});
    return foundry.utils.deepClone(value);
  }

  /* -------------------------------------------- */

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData   Candidate source data of the root model
   * @param {any} fieldData       The value of this field within the source data
   */
  migrateSource(sourceData, fieldData) {
    const cls = this.getModelForType(fieldData.type);
    if ( cls ) cls.migrateDataSafe(fieldData);
  }
}

const { SchemaField: SchemaField$A, StringField: StringField$L } = foundry.data.fields;

/**
 * Data field for class & subclass spellcasting information.
 *
 * @property {string} progression          Spellcasting progression (e.g. full, half, pact).
 * @property {string} ability              Ability used for spell attacks and save DCs.
 * @property {object} preparation
 * @property {string} preparation.formula  Formula used to calculate max prepared spells, if a prepared caster.
 */
class SpellcastingField extends SchemaField$A {
  constructor(fields={}, options={}) {
    fields = {
      progression: new StringField$L({
        initial: "none",
        blank: false,
        label: "DND5E.SpellProgression"
      }),
      ability: new StringField$L({ label: "DND5E.SpellAbility" }),
      preparation: new SchemaField$A({
        formula: new FormulaField({ label: "DND5E.SpellPreparation.Formula" })
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.Spellcasting", ...options });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare data for this field. Should be called during the `prepareFinalData` stage.
   * @this {ItemDataModel}
   * @param {object} rollData  Roll data used for formula replacements.
   */
  static prepareData(rollData) {
    this.spellcasting.preparation.max = simplifyBonus(this.spellcasting.preparation.formula, rollData);

    // Temp method for determining spellcasting type until this data is available directly using advancement
    if ( CONFIG.DND5E.spellcastingTypes[this.spellcasting.progression] ) {
      this.spellcasting.type = this.spellcasting.progression;
    } else this.spellcasting.type = Object.entries(CONFIG.DND5E.spellcastingTypes).find(([, { progression }]) =>
      progression?.[this.spellcasting.progression]
    )?.[0];

    const actor = this.parent.actor;
    if ( !actor ) return;
    this.spellcasting.levels = this.levels ?? this.parent.class?.system.levels;

    // Prepare attack bonus and save DC
    const ability = actor.system.abilities?.[this.spellcasting.ability];
    const mod = ability?.mod ?? 0;
    const modProf = mod + (actor.system.attributes?.prof ?? 0);
    const msak = simplifyBonus(actor.system.bonuses?.msak?.attack, rollData);
    const rsak = simplifyBonus(actor.system.bonuses?.rsak?.attack, rollData);
    this.spellcasting.attack = modProf + (msak === rsak ? msak : 0);
    this.spellcasting.save = ability?.dc ?? (8 + modProf);
  }
}

const { SchemaField: SchemaField$z, HTMLField: HTMLField$8 } = foundry.data.fields;

/**
 * Data model template with item description & source.
 *
 * @property {object} description               Various item descriptions.
 * @property {string} description.value         Full item description.
 * @property {string} description.chat          Description displayed in chat card.
 * @property {string} identifier                Identifier slug for this item.
 * @property {SourceData} source                Adventure or sourcebook where this item originated.
 * @mixin
 */
class ItemDescriptionTemplate extends SystemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      description: new SchemaField$z({
        value: new HTMLField$8({required: true, nullable: true, label: "DND5E.Description"}),
        chat: new HTMLField$8({required: true, nullable: true, label: "DND5E.DescriptionChat"})
      }),
      identifier: new IdentifierField({ required: true, label: "DND5E.Identifier" }),
      source: new SourceField()
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ItemDescriptionTemplate.#migrateSource(source);
  }

  /* -------------------------------------------- */

  /**
   * Convert source string into custom object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSource(source) {
    if ( ("source" in source) && (foundry.utils.getType(source.source) !== "Object") ) {
      source.source = { custom: source.source };
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the source label.
   */
  prepareDescriptionData() {
    const uuid = this.parent.flags.dnd5e?.sourceId ?? this.parent._stats?.compendiumSource ?? this.parent.uuid;
    SourceField.prepareData.call(this.source, uuid);
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * What properties can be used for this item?
   * @returns {Set<string>}
   */
  get validProperties() {
    return new Set(CONFIG.DND5E.validProperties[this.parent.type] ?? []);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Create the properties filter configuration for a type.
   * @param {string} type  Item type.
   * @returns {CompendiumBrowserFilterDefinitionEntry}
   */
  static compendiumBrowserPropertiesFilter(type) {
    return {
      label: "DND5E.Properties",
      type: "set",
      config: {
        choices: Object.entries(CONFIG.DND5E.itemProperties).reduce((obj, [k, v]) => {
          if ( CONFIG.DND5E.validProperties[type]?.has(k) ) obj[k] = v;
          return obj;
        }, {}),
        keyPath: "system.properties",
        multiple: true
      }
    };
  }
}

/**
 * Field that stores activities on an item.
 */
class ActivitiesField extends MappingField {
  constructor(options) {
    super(new ActivityField(), options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(value, model, options) {
    const activities = Object.values(super.initialize(value, model, options));
    activities.sort((a, b) => a.sort - b.sort);
    return new ActivityCollection(model, activities);
  }
}

/* -------------------------------------------- */

/**
 * Field that stores activity data and swaps class based on activity type.
 */
class ActivityField extends foundry.data.fields.ObjectField {

  /** @override */
  static recursive = true;

  /* -------------------------------------------- */

  /**
   * Get the document type for this activity.
   * @param {object} value            Activity data being prepared.
   * @returns {typeof Activity|null}  Activity document type.
   */
  getModel(value) {
    return CONFIG.DND5E.activityTypes[value.type]?.documentClass ?? null;
  }

  /* -------------------------------------------- */

  /** @override */
  _cleanType(value, options) {
    if ( !(typeof value === "object") ) value = {};

    const cls = this.getModel(value);
    if ( cls ) return cls.cleanData(value, options);
    return value;
  }

  /* -------------------------------------------- */

  /** @override */
  initialize(value, model, options = {}) {
    const cls = this.getModel(value);
    if ( cls ) return new cls(value, { parent: model, ...options });
    return foundry.utils.deepClone(value);
  }

  /* -------------------------------------------- */

  /**
   * Migrate this field's candidate source data.
   * @param {object} sourceData  Candidate source data of the root model.
   * @param {any} fieldData      The value of this field within the source data.
   */
  migrateSource(sourceData, fieldData) {
    const cls = this.getModel(fieldData);
    if ( cls ) cls.migrateDataSafe(fieldData);
  }
}

/* -------------------------------------------- */

/**
 * Specialized collection type for stored activities.
 * @param {DataModel} model     The parent DataModel to which this ActivityCollection belongs.
 * @param {Activity[]} entries  The activities to store.
 */
class ActivityCollection extends Collection {
  constructor(model, entries) {
    super();
    this.#model = model;
    for ( const entry of entries ) {
      if ( !(entry instanceof BaseActivityData) ) continue;
      this.set(entry._id, entry);
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The parent DataModel to which this ActivityCollection belongs.
   * @type {DataModel}
   */
  #model;

  /* -------------------------------------------- */

  /**
   * Pre-organized arrays of activities by type.
   * @type {Map<string, Set<string>>}
   */
  #types = new Map();

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Fetch an array of activities of a certain type.
   * @param {string} type  Activity type.
   * @returns {Activity[]}
   */
  getByType(type) {
    return Array.from(this.#types.get(type) ?? []).map(key => this.get(key));
  }

  /* -------------------------------------------- */

  /**
   * Generator that yields activities for each of the provided types.
   * @param {string[]} types  Types to fetch.
   * @yields {Activity}
   */
  *getByTypes(...types) {
    for ( const type of types ) {
      for ( const activity of this.getByType(type) ) yield activity;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  set(key, value) {
    if ( !this.#types.has(value.type) ) this.#types.set(value.type, new Set());
    this.#types.get(value.type).add(key);
    return super.set(key, value);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  delete(key) {
    this.#types.get(this.get(key)?.type)?.delete(key);
    return super.delete(key);
  }

  /* -------------------------------------------- */

  /**
   * Test the given predicate against every entry in the Collection.
   * @param {function(*, number, ActivityCollection): boolean} predicate  The predicate.
   * @returns {boolean}
   */
  every(predicate) {
    return this.reduce((pass, v, i) => pass && predicate(v, i, this), true);
  }

  /* -------------------------------------------- */

  /**
   * Convert the ActivityCollection to an array of simple objects.
   * @param {boolean} [source=true]  Draw data for contained Documents from the underlying data source?
   * @returns {object[]}             The extracted array of primitive objects.
   */
  toObject(source=true) {
    return this.map(doc => doc.toObject(source));
  }
}

/**
 * @typedef AdvantageModeData
 * @property {number|null} override               Whether the mode has been entirely overridden.
 * @property {AdvantageModeCounts} advantages     The advantage counts.
 * @property {AdvantageModeCounts} disadvantages  The disadvantage counts.
 */

/**
 * @typedef AdvantageModeCounts
 * @property {number} count          The number of applications of this mode.
 * @property {boolean} [suppressed]  Whether this mode is suppressed.
 */

/**
 * Subclass of NumberField that tracks the number of changes made to a roll mode.
 */
class AdvantageModeField extends foundry.data.fields.NumberField {
  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {
      choices: AdvantageModeField.#values,
      initial: 0,
      label: "DND5E.AdvantageMode"
    });
  }

  /* -------------------------------------------- */

  /**
   * Allowed advantage mode values.
   * @type {number[]}
   */
  static #values = [-1, 0, 1];

  /* -------------------------------------------- */
  /*  Active Effect Integration                   */
  /* -------------------------------------------- */

  /** @override */
  _applyChangeAdd(value, delta, model, change) {
    // Add a source of advantage or disadvantage.
    if ( (delta !== -1) && (delta !== 1) ) return value;
    const counts = this.constructor.getCounts(model, change);
    if ( delta === 1 ) counts.advantages.count++;
    else counts.disadvantages.count++;
    return this.constructor.resolveMode(model, change, counts);
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeDowngrade(value, delta, model, change) {
    // Downgrade the roll so that it can no longer benefit from advantage.
    if ( (delta !== -1) && (delta !== 0) ) return value;
    const counts = this.constructor.getCounts(model, change);
    counts.advantages.suppressed = true;
    if ( delta === -1 ) counts.disadvantages.count++;
    return this.constructor.resolveMode(model, change, counts);
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeMultiply(value, delta, model, change) {
    return value;
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeOverride(value, delta, model, change) {
    // Force a given roll mode.
    if ( (delta === -1) || (delta === 0) || (delta === 1) ) {
      this.constructor.getCounts(model, change).override = delta;
      return delta;
    }
    return value;
  }

  /* -------------------------------------------- */

  /** @override */
  _applyChangeUpgrade(value, delta, model, change) {
    // Upgrade the roll so that it can no longer be penalised by disadvantage.
    if ( (delta !== 1) && (delta !== 0) ) return value;
    const counts = this.constructor.getCounts(model, change);
    counts.disadvantages.suppressed = true;
    if ( delta === 1 ) counts.advantages.count++;
    return this.constructor.resolveMode(model, change, counts);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve the advantage/disadvantage counts from the model.
   * @param {DataModel} model          The model the change is applied to.
   * @param {EffectChangeData} change  The change to apply.
   * @returns {AdvantageModeData}
   */
  static getCounts(model, change) {
    const parentKey = change.key.substring(0, change.key.lastIndexOf("."));
    const roll = foundry.utils.getProperty(model, parentKey) ?? {};
    return roll.modeCounts ??= {
      override: null,
      advantages: { count: 0, suppressed: false },
      disadvantages: { count: 0, suppressed: false }
    };
  }

  /* -------------------------------------------- */

  /**
   * Resolve multiple sources of advantage and disadvantage into a single roll mode per the game rules.
   * @param {DataModel} model             The model the change is applied to.
   * @param {EffectChangeData} change     The change to applied.
   * @param {AdvantageModeData} [counts]  The current advantage/disadvantage counts.
   * @returns {number}                    An integer in the interval [-1, 1], indicating advantage (1),
   *                                      disadvantage (-1), or neither (0).
   */
  static resolveMode(model, change, counts) {
    const { override, advantages, disadvantages } = counts ?? this.getCounts(model, change);
    if ( override !== null ) return override;
    const src = foundry.utils.getProperty(model._source, change.key) ?? 0;
    const advantageCount = advantages.suppressed ? 0 : advantages.count + Number(src === 1);
    const disadvantageCount = disadvantages.suppressed ? 0 : disadvantages.count + Number(src === -1);
    return Math.sign(advantageCount) - Math.sign(disadvantageCount);
  }
}

/**
 * @typedef {StringFieldOptions} LocalDocumentFieldOptions
 * @property {boolean} [fallback=false]  Display the string value if no matching item is found.
 */

/**
 * A mirror of ForeignDocumentField that references a Document embedded within this Document.
 *
 * @param {typeof Document} model              The local DataModel class definition which this field should link to.
 * @param {LocalDocumentFieldOptions} options  Options which configure the behavior of the field.
 */
class LocalDocumentField extends foundry.data.fields.DocumentIdField {
  constructor(model, options={}) {
    if ( !foundry.utils.isSubclass(model, foundry.abstract.DataModel) ) {
      throw new Error("A ForeignDocumentField must specify a DataModel subclass as its type");
    }

    super(options);
    this.model = model;
  }

  /* -------------------------------------------- */

  /**
   * A reference to the model class which is stored in this field.
   * @type {typeof Document}
   */
  model;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get _defaults() {
    return foundry.utils.mergeObject(super._defaults, {
      nullable: true,
      readonly: false,
      idOnly: false,
      fallback: false
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _cast(value) {
    if ( typeof value === "string" ) return value;
    if ( (value instanceof this.model) ) return value._id;
    throw new Error(`The value provided to a LocalDocumentField must be a ${this.model.name} instance.`);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _validateType(value) {
    if ( !this.options.fallback ) super._validateType(value);
  }

  /* -------------------------------------------- */

  /**
   * Step up through model's parents to find the specified collection.
   * @param {DataModel} model
   * @param {string} collection
   * @returns {EmbeddedCollection|void}
   */
  _findCollection(model, collection) {
    if ( !model.parent ) return;
    try {
      return model.parent.getEmbeddedCollection(collection);
    } catch(err) {
      return model.parent[collection] ?? this._findCollection(model.parent, collection);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  initialize(value, model, options={}) {
    if ( this.idOnly ) return this.options.fallback || foundry.data.validators.isValidId(value) ? value : null;
    const collection = this._findCollection(model, this.model.metadata.collection);
    return () => {
      const document = collection?.get(value);
      if ( !document ) return this.options.fallback ? value : null;
      if ( this.options.fallback ) Object.defineProperty(document, "toString", {
        value: () => document.name,
        configurable: true,
        enumerable: false
      });
      return document;
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  toObject(value) {
    return value?._id ?? value;
  }
}

var _module$r = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivitiesField: ActivitiesField,
  ActivityCollection: ActivityCollection,
  ActivityField: ActivityField,
  AdvancementDataField: AdvancementDataField,
  AdvancementField: AdvancementField,
  AdvantageModeField: AdvantageModeField,
  FormulaField: FormulaField,
  IdentifierField: IdentifierField,
  LocalDocumentField: LocalDocumentField,
  MappingField: MappingField
});

const {
  ArrayField: ArrayField$f, BooleanField: BooleanField$q, DocumentIdField: DocumentIdField$3, EmbeddedDataField: EmbeddedDataField$4, IntegerSortField: IntegerSortField$1, NumberField: NumberField$v, StringField: StringField$K
} = foundry.data.fields;

/**
 * Data model template representing a background & class's starting equipment.
 *
 * @property {EquipmentEntryData[]} startingEquipment  Different equipment entries that will be granted.
 * @property {string} wealth                           Formula used to determine starting wealth.
 */
class StartingEquipmentTemplate extends SystemDataModel {
  static defineSchema() {
    return {
      startingEquipment: new ArrayField$f(new EmbeddedDataField$4(EquipmentEntryData), {required: true}),
      wealth: new FormulaField({ label: "DND5E.StartingEquipment.Wealth.Label",
        hint: "DND5E.StartingEquipment.Wealth.Hint" })
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * HTML formatted description of the starting equipment on this item.
   * @type {string}
   */
  get startingEquipmentDescription() {
    const topLevel = this.startingEquipment.filter(e => !e.group);
    if ( !topLevel.length ) return "";

    // If more than one entry, display as an unordered list (like for classes)
    if ( topLevel.length > 1 ) return `<ul>${topLevel.map(e => `<li>${e.label}</li>`).join("")}</ul>`;

    // Otherwise display as its own paragraph (like for backgrounds)
    return `<p>${game.i18n.getListFormatter().format(topLevel.map(e => e.label))}</p>`;
  }
}


/**
 * Data for a single entry in the equipment list.
 *
 * @property {string} _id                     Unique ID of this entry.
 * @property {string|null} group              Parent entry that contains this one.
 * @property {number} sort                    Sorting order of this entry.
 * @property {string} type                    Entry type as defined in `EquipmentEntryData#TYPES`.
 * @property {number} [count]                 Number of items granted. If empty, assumed to be `1`.
 * @property {string} [key]                   Category or item key unless type is "linked", in which case it is a UUID.
 * @property {boolean} [requiresProficiency]  Is this only a valid item if character already has the
 *                                            required proficiency.
 */
class EquipmentEntryData extends foundry.abstract.DataModel {

  /**
   * Types that group together child entries.
   * @enum {string}
   */
  static GROUPING_TYPES = {
    OR: "DND5E.StartingEquipment.Operator.OR",
    AND: "DND5E.StartingEquipment.Operator.AND"
  };

  /**
   * Types that contain an option for the player.
   * @enum {string}
   */
  static OPTION_TYPES = {
    // Category types
    armor: "DND5E.StartingEquipment.Choice.Armor",
    tool: "DND5E.StartingEquipment.Choice.Tool",
    weapon: "DND5E.StartingEquipment.Choice.Weapon",
    focus: "DND5E.StartingEquipment.Choice.Focus",

    // Generic item type
    linked: "DND5E.StartingEquipment.SpecificItem"
  };

  /**
   * Equipment entry types.
   * @type {Record<string, string>}
   */
  static get TYPES() {
    return { ...this.GROUPING_TYPES, ...this.OPTION_TYPES };
  }

  /* -------------------------------------------- */

  /**
   * Where in `CONFIG.DND5E` to find the type category labels.
   * @enum {{ label: string, config: string }}
   */
  static CATEGORIES = {
    armor: {
      label: "DND5E.Armor",
      config: "armorTypes"
    },
    focus: {
      label: "DND5E.Focus.Label",
      config: "focusTypes"
    },
    tool: {
      label: "TYPES.Item.tool",
      config: "toolTypes"
    },
    weapon: {
      label: "TYPES.Item.weapon",
      config: "weaponProficiencies"
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      _id: new DocumentIdField$3({initial: () => foundry.utils.randomID()}),
      group: new StringField$K({nullable: true, initial: null}),
      sort: new IntegerSortField$1(),
      type: new StringField$K({required: true, initial: "OR", choices: this.TYPES}),
      count: new NumberField$v({initial: undefined}),
      key: new StringField$K({initial: undefined}),
      requiresProficiency: new BooleanField$q({label: "DND5E.StartingEquipment.Proficient.Label"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Get any children represented by this entry in order.
   * @returns {EquipmentEntryData[]}
   */
  get children() {
    if ( !(this.type in this.constructor.GROUPING_TYPES) ) return [];
    return this.parent.startingEquipment
      .filter(entry => entry.group === this._id)
      .sort((lhs, rhs) => lhs.sort - rhs.sort);
  }

  /* -------------------------------------------- */

  /**
   * Transform this entry into a human readable label.
   * @type {string}
   */
  get label() {
    let label;

    switch ( this.type ) {
      // For AND/OR, use a simple conjunction/disjunction list (e.g. "first, second, and third")
      case "AND":
      case "OR":
        return game.i18n.getListFormatter({type: this.type === "AND" ? "conjunction" : "disjunction", style: "long"})
          .format(this.children.map(c => c.label).filter(l => l));

      // For linked type, fetch the name using the index
      case "linked":
        label = linkForUuid(this.key);
        break;

      // For category types, grab category information from config
      default:
        label = this.categoryLabel;
        break;
    }

    if ( !label ) return "";
    if ( this.count > 1 ) label = `${formatNumber(this.count)}&times; ${label}`;
    else if ( this.type !== "linked" ) label = game.i18n.format("DND5E.TraitConfigChooseAnyUncounted", { type: label });
    if ( (this.type === "linked") && this.requiresProficiency ) {
      label += ` (${game.i18n.localize("DND5E.StartingEquipment.IfProficient").toLowerCase()})`;
    }
    return label;
  }

  /* -------------------------------------------- */

  /**
   * Blank label if no key is specified for a choice type.
   * @type {string}
   */
  get blankLabel() {
    return game.i18n.localize(this.constructor.CATEGORIES[this.type]?.label) ?? "";
  }

  /* -------------------------------------------- */

  /**
   * Get the label for a category.
   * @type {string}
   */
  get categoryLabel() {
    const configEntry = this.keyOptions[this.key];
    let label = configEntry?.label ?? configEntry;
    if ( !label ) return this.blankLabel.toLowerCase();

    if ( this.type === "weapon" ) label = game.i18n.format("DND5E.WeaponCategory", { category: label });
    return label.toLowerCase();
  }

  /* -------------------------------------------- */

  /**
   * Build a list of possible key options for this entry's type.
   * @returns {Record<string, string>}
   */
  get keyOptions() {
    const config = foundry.utils.deepClone(CONFIG.DND5E[this.constructor.CATEGORIES[this.type]?.config]);
    if ( this.type === "weapon" ) foundry.utils.mergeObject(config, CONFIG.DND5E.weaponTypes);
    return Object.entries(config).reduce((obj, [k, v]) => {
      obj[k] = foundry.utils.getType(v) === "Object" ? v.label : v;
      return obj;
    }, {});
  }
}

var startingEquipment = /*#__PURE__*/Object.freeze({
  __proto__: null,
  EquipmentEntryData: EquipmentEntryData,
  default: StartingEquipmentTemplate
});

const { ArrayField: ArrayField$e, BooleanField: BooleanField$p, NumberField: NumberField$u, SchemaField: SchemaField$y, SetField: SetField$m, StringField: StringField$J } = foundry.data.fields;

/**
 * Data definition for Class items.
 * @mixes ItemDescriptionTemplate
 * @mixes StartingEquipmentTemplate
 *
 * @property {number} levels                    Current number of levels in this class.
 * @property {object} primaryAbility
 * @property {Set<string>} primaryAbility.value List of primary abilities used by this class.
 * @property {boolean} primaryAbility.all       If multiple abilities are selected, does multiclassing require all of
 *                                              them to be 13 or just one.
 * @property {object} hd                        Object describing hit dice properties.
 * @property {string} hd.additional             Additional hit dice beyond the level of the class.
 * @property {string} hd.denomination           Denomination of hit dice available as defined in `DND5E.hitDieTypes`.
 * @property {number} hd.spent                  Number of hit dice consumed.
 * @property {object[]} advancement             Advancement objects for this class.
 * @property {SpellcastingField} spellcasting   Details on class's spellcasting ability.
 */
class ClassData extends ItemDataModel.mixin(ItemDescriptionTemplate, StartingEquipmentTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CLASS", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      levels: new NumberField$u({ required: true, nullable: false, integer: true, min: 0, initial: 1 }),
      primaryAbility: new SchemaField$y({
        value: new SetField$m(new StringField$J()),
        all: new BooleanField$p({ initial: true })
      }),
      hd: new SchemaField$y({
        additional: new FormulaField({ deterministic: true, required: true }),
        denomination: new StringField$J({
          required: true, initial: "d6", blank: false,
          validate: v => /d\d+/.test(v), validationError: "must be a dice value in the format d#"
        }),
        spent: new NumberField$u({ required: true, nullable: false, integer: true, initial: 0, min: 0 })
      }),
      advancement: new ArrayField$e(new AdvancementField(), { label: "DND5E.AdvancementTitle" }),
      spellcasting: new SpellcastingField()
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["hasSpellcasting", {
        label: "DND5E.CompendiumBrowser.Filters.HasSpellcasting",
        type: "boolean",
        createFilter: (filters, value, def) => {
          if ( value === 0 ) return;
          const filter = { k: "system.spellcasting.progression", v: "none" };
          if ( value === -1 ) filters.push(filter);
          else filters.push({ o: "NOT", v: filter });
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.spellcasting.preparation.value = 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.isOriginalClass = this.parent.isOriginalClass;
    const rollData = this.parent.getRollData({ deterministic: true });
    SpellcastingField.prepareData.call(this, rollData);
    this.hd.additional = this.hd.additional ? Roll.create(this.hd.additional, rollData).evaluateSync().total : 0;
    this.hd.max = Math.max(this.levels + this.hd.additional, 0);
    this.hd.value = Math.max(this.hd.max - this.hd.spent, 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    const context = await super.getFavoriteData();
    if ( this.parent.subclass ) context.subtitle = this.parent.subclass.name;
    context.value = this.levels;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: context.itemType }];
    context.singleDescription = true;
    context.parts = ["dnd5e.details-class", "dnd5e.details-spellcasting", "dnd5e.details-starting-equipment"];
    context.primaryAbilities = Object.entries(CONFIG.DND5E.abilities).map(([value, data]) => ({
      value, label: data.label, selected: this.primaryAbility.value.has(value)
    }));
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ClassData.#migrateHitDice(source);
    ClassData.#migrateLevels(source);
    ClassData.#migrateSpellcastingData(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the hit dice data.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateHitDice(source) {
    if ( ("hitDice" in source) && (!source.hd || !("denomination" in source.hd)) ) {
      source.hd ??= {};
      source.hd.denomination = source.hitDice;
      delete source.hitDice;
    }

    if ( ("hitDiceUsed" in source) && (!source.hd || !("spent" in source.hd)) ) {
      source.hd ??= {};
      source.hd.spent = source.hitDiceUsed ?? 0;
      delete source.hitDiceUsed;
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the class levels.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateLevels(source) {
    if ( typeof source.levels !== "string" ) return;
    if ( source.levels === "" ) source.levels = 1;
    else if ( Number.isNumeric(source.levels) ) source.levels = Number(source.levels);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the class's spellcasting string to object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSpellcastingData(source) {
    if ( source.spellcasting?.progression === "" ) source.spellcasting.progression = "none";
    if ( typeof source.spellcasting !== "string" ) return;
    source.spellcasting = {
      progression: source.spellcasting,
      ability: ""
    };
  }

  /* -------------------------------------------- */

  /**
   * Migrate the class's saves & skills into TraitAdvancements.
   * @param {object} source  The candidate source data from which the model will be constructed.
   * @protected
   */
  static _migrateTraitAdvancement(source) {
    const system = source.system;
    if ( !system?.advancement || system.advancement.find(a => a.type === "Trait") ) return;
    let needsMigration = false;

    if ( system.saves?.length ) {
      const savesData = {
        type: "Trait",
        level: 1,
        configuration: {
          grants: system.saves.map(t => `saves:${t}`)
        }
      };
      savesData.value = {
        chosen: savesData.configuration.grants
      };
      system.advancement.push(new TraitAdvancement(savesData).toObject());
      delete system.saves;
      needsMigration = true;
    }

    if ( system.skills?.choices?.length ) {
      const skillsData = {
        type: "Trait",
        level: 1,
        configuration: {
          choices: [{
            count: system.skills.number ?? 1,
            pool: system.skills.choices.map(t => `skills:${t}`)
          }]
        }
      };
      if ( system.skills.value?.length ) {
        skillsData.value = {
          chosen: system.skills.value.map(t => `skills:${t}`)
        };
      }
      system.advancement.push(new TraitAdvancement(skillsData).toObject());
      delete system.skills;
      needsMigration = true;
    }

    if ( needsMigration ) foundry.utils.setProperty(source, "flags.dnd5e.persistSourceMigration", true);
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreate(data, options, userId) {
    await super._onCreate(data, options, userId);
    const actor = this.parent.actor;
    if ( !actor || (userId !== game.user.id) ) return;

    if ( actor.type === "character" ) {
      const pc = actor.items.get(actor.system.details.originalClass);
      if ( !pc ) await actor._assignPrimaryClass();
    }

    if ( !actor.system.attributes?.spellcasting && this.parent.spellcasting?.ability ) {
      await actor.update({ "system.attributes.spellcasting": this.parent.spellcasting.ability });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    if ( !("levels" in (changed.system ?? {})) ) return;

    // Check to make sure the updated class level isn't below zero
    if ( changed.system.levels <= 0 ) {
      ui.notifications.warn("DND5E.MaxClassLevelMinimumWarn", { localize: true });
      changed.system.levels = 1;
    }

    // Check to make sure the updated class level doesn't exceed level cap
    if ( changed.system.levels > CONFIG.DND5E.maxLevel ) {
      ui.notifications.warn(game.i18n.format("DND5E.MaxClassLevelExceededWarn", { max: CONFIG.DND5E.maxLevel }));
      changed.system.levels = CONFIG.DND5E.maxLevel;
    }

    if ( this.parent.actor?.type !== "character" ) return;

    // Check to ensure the updated character doesn't exceed level cap
    const newCharacterLevel = this.parent.actor.system.details.level + (changed.system.levels - this.levels);
    if ( newCharacterLevel > CONFIG.DND5E.maxLevel ) {
      ui.notifications.warn(game.i18n.format("DND5E.MaxCharacterLevelExceededWarn", { max: CONFIG.DND5E.maxLevel }));
      changed.system.levels -= newCharacterLevel - CONFIG.DND5E.maxLevel;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( userId !== game.user.id ) return;
    if ( this.parent.id === this.parent.actor?.system.details?.originalClass ) {
      this.parent.actor._assignPrimaryClass();
    }
  }
}

/**
 * A template for currently held currencies.
 *
 * @property {object} currency  Object containing currencies as numbers.
 * @mixin
 */
class CurrencyTemplate extends SystemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      currency: new MappingField(new foundry.data.fields.NumberField({
        required: true, nullable: false, integer: true, min: 0, initial: 0
      }), {initialKeys: CONFIG.DND5E.currencies, initialKeysOnly: true, label: "DND5E.Currency"})
    };
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Get the weight of all of the currency. Always returns 0 if currency weight is disabled in settings.
   * @returns {number}
   */
  get currencyWeight() {
    if ( !game.settings.get("dnd5e", "currencyWeight") ) return 0;
    const count = Object.values(this.currency).reduce((count, value) => count + value, 0);
    const currencyPerWeight = game.settings.get("dnd5e", "metricWeightUnits")
      ? CONFIG.DND5E.encumbrance.currencyPerWeight.metric
      : CONFIG.DND5E.encumbrance.currencyPerWeight.imperial;
    return count / currencyPerWeight;
  }
}

const { BooleanField: BooleanField$o, StringField: StringField$I } = foundry.data.fields;

/**
 * Data model template with information on items that can be attuned and equipped.
 *
 * @property {string} attunement  Attunement information as defined in `DND5E.attunementTypes`.
 * @property {boolean} attuned    Is this item attuned on its owning actor?
 * @property {boolean} equipped   Is this item equipped on its owning actor?
 * @mixin
 */
class EquippableItemTemplate extends SystemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      attunement: new StringField$I({required: true, label: "DND5E.Attunement"}),
      attuned: new BooleanField$o({label: "DND5E.Attuned"}),
      equipped: new BooleanField$o({required: true, label: "DND5E.Equipped"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Create attunement filter configuration.
   * @returns {CompendiumBrowserFilterDefinitionEntry}
   */
  static get compendiumBrowserAttunementFilter() {
    return {
      label: "DND5E.Attunement",
      type: "boolean",
      createFilter: (filters, value, def) => {
        if ( value === 0 ) return;
        const filter = { k: "system.attunement", o: "in", v: ["required", 1] };
        if ( value === 1 ) filters.push(filter);
        else filters.push({ o: "NOT", v: filter });
      }
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    EquippableItemTemplate.#migrateAttunement(source);
    EquippableItemTemplate.#migrateEquipped(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the item's attuned boolean to attunement string.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateAttunement(source) {
    switch ( source.attunement ) {
      case 2: source.attuned = true;
      case 1: source.attunement = "required"; break;
      case 0: source.attunement = ""; break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the equipped field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateEquipped(source) {
    if ( !("equipped" in source) ) return;
    if ( (source.equipped === null) || (source.equipped === undefined) ) source.equipped = false;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Ensure items that cannot be attuned are not marked as attuned.
   */
  prepareFinalEquippableData() {
    if ( !this.attunement ) this.attuned = false;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Chat properties for equippable items.
   * @type {string[]}
   */
  get equippableItemCardProperties() {
    return [
      this.attunement === "required" ? CONFIG.DND5E.attunementTypes.required : null,
      game.i18n.localize(this.equipped ? "DND5E.Equipped" : "DND5E.Unequipped"),
      ("proficient" in this) ? CONFIG.DND5E.proficiencyLevels[this.prof?.multiplier || 0] : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Are the magical properties of this item, such as magical bonuses to armor & damage, available?
   * @type {boolean}
   */
  get magicAvailable() {
    const attunement = this.attuned || (this.attunement !== "required");
    return attunement && this.properties.has("mgc") && this.validProperties.has("mgc");
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Set as equipped for NPCs, and unequipped for PCs.
   * @param {object} data     The initial data object provided to the document creation request.
   * @param {object} options  Additional options which modify the creation request.
   * @param {User} user       The User requesting the document creation.
   */
  preCreateEquipped(data, options, user) {
    if ( ["character", "npc"].includes(this.parent.actor?.type)
      && !foundry.utils.hasProperty(data, "system.equipped") ) {
      this.updateSource({ equipped: this.parent.actor.type === "npc" });
    }
  }
}

const { BooleanField: BooleanField$n, SchemaField: SchemaField$x, StringField: StringField$H, HTMLField: HTMLField$7 } = foundry.data.fields;

/**
 * Data model template for items that can be identified.
 *
 * @property {boolean} identified               Has this item been identified?
 * @property {object} unidentified
 * @property {string} unidentified.name         Name of the item when it is unidentified.
 * @property {string} unidentified.description  Description displayed if item is unidentified.
 * @mixin
 */
class IdentifiableTemplate extends SystemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      identified: new BooleanField$n({required: true, initial: true, label: "DND5E.Identified"}),
      unidentified: new SchemaField$x({
        name: new StringField$H({label: "DND5E.NameUnidentified"}),
        description: new HTMLField$7({label: "DND5E.DescriptionUnidentified"})
      })
    };
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    IdentifiableTemplate.#migrateUnidentified(source);
  }

  /* -------------------------------------------- */

  /**
   * Move unidentified description into new location.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateUnidentified(source) {
    if ( foundry.utils.hasProperty(source, "description.unidentified")
      && !foundry.utils.getProperty(source, "unidentified.description") ) {
      source.unidentified ??= {};
      source.unidentified.description = source.description.unidentified;
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the unidentified name for the item.
   */
  prepareIdentifiable() {
    if ( !this.identified && this.unidentified.name ) {
      this.parent.name = this.unidentified.name;
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * If no unidentified name or description are set when the identified checkbox is unchecked, then fetch values
   * from base item if possible.
   * @param {object} changed            The differential data that is changed relative to the document's prior values.
   * @param {object} options            Additional options which modify the update request
   * @param {documents.BaseUser} user   The User requesting the document update
   * @returns {Promise<boolean|void>}   A return value of false indicates the update operation should be cancelled.
   * @see {Document#_preUpdate}
   * @protected
   */
  async preUpdateIdentifiable(changed, options, user) {
    if ( !foundry.utils.hasProperty(changed, "system.identified") || changed.system.identified ) return;

    const fetchName = !foundry.utils.getProperty(changed, "system.unidentified.name") && !this.unidentified.name;
    const fetchDesc = !foundry.utils.getProperty(changed, "system.unidentified.description")
      && !this.unidentified.description;
    if ( !fetchName && !fetchDesc ) return;

    const baseItem = await getBaseItem(this.type?.identifier ?? "", { fullItem: fetchDesc });

    // If a base item is set, fetch that and use its name/description
    if ( baseItem ) {
      if ( fetchName ) {
        foundry.utils.setProperty(changed, "system.unidentified.name", game.i18n.format(
          "DND5E.Unidentified.DefaultName", { name: baseItem.name }
        ));
      }
      if ( fetchDesc ) {
        foundry.utils.setProperty(changed, "system.unidentified.description", baseItem.system.description.value);
      }
      return;
    }

    // Otherwise, set the name to match the item type
    if ( fetchName ) foundry.utils.setProperty(changed, "system.unidentified.name", game.i18n.format(
      "DND5E.Unidentified.DefaultName", { name: game.i18n.localize(CONFIG.Item.typeLabels[this.parent.type]) }
    ));
  }
}

const { ForeignDocumentField: ForeignDocumentField$4, NumberField: NumberField$t, SchemaField: SchemaField$w, StringField: StringField$G } = foundry.data.fields;

/**
 * Data model template with information on physical items.
 *
 * @property {string} container           Container within which this item is located.
 * @property {number} quantity            Number of items in a stack.
 * @property {object} weight
 * @property {number} weight.value        Item's weight.
 * @property {string} weight.units        Units used to measure the weight.
 * @property {object} price
 * @property {number} price.value         Item's cost in the specified denomination.
 * @property {string} price.denomination  Currency denomination used to determine price.
 * @property {string} rarity              Item rarity as defined in `DND5E.itemRarity`.
 * @mixin
 */
class PhysicalItemTemplate extends SystemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      container: new ForeignDocumentField$4(foundry.documents.BaseItem, {
        idOnly: true, label: "DND5E.Container"
      }),
      quantity: new NumberField$t({
        required: true, nullable: false, integer: true, initial: 1, min: 0, label: "DND5E.Quantity"
      }),
      weight: new SchemaField$w({
        value: new NumberField$t({
          required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Weight"
        }),
        units: new StringField$G({
          required: true, blank: false, label: "DND5E.UNITS.WEIGHT.Label", initial: () => defaultUnits("weight")
        })
      }, {label: "DND5E.Weight"}),
      price: new SchemaField$w({
        value: new NumberField$t({
          required: true, nullable: false, initial: 0, min: 0, label: "DND5E.Price"
        }),
        denomination: new StringField$G({
          required: true, blank: false, initial: "gp", label: "DND5E.Currency"
        })
      }, {label: "DND5E.Price"}),
      rarity: new StringField$G({required: true, blank: true, label: "DND5E.Rarity"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Maximum depth items can be nested in containers.
   * @type {number}
   */
  static MAX_DEPTH = 5;

  /* -------------------------------------------- */

  /**
   * Create filter configurations shared by all physical items.
   * @returns {[string, CompendiumBrowserFilterDefinitionEntry][]}
   */
  static get compendiumBrowserPhysicalItemFilters() {
    return [
      ["price", {
        label: "DND5E.Price",
        type: "range",
        config: {
          keyPath: "system.price.value"
        }
      }],
      ["rarity", {
        label: "DND5E.Rarity",
        type: "set",
        config: {
          blank: game.i18n.localize("DND5E.ItemRarityMundane").capitalize(),
          choices: Object.entries(CONFIG.DND5E.itemRarity).reduce((obj, [key, label]) => {
            obj[key] = { label: label.capitalize() };
            return obj;
          }, {}),
          keyPath: "system.rarity"
        }
      }]
    ];
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Get a human-readable label for the price and denomination.
   * @type {string}
   */
  get priceLabel() {
    const { value, denomination } = this.price;
    const hasPrice = value && (denomination in CONFIG.DND5E.currencies);
    return hasPrice ? `${value} ${CONFIG.DND5E.currencies[denomination].label}` : null;
  }

  /* -------------------------------------------- */

  /**
   * The weight of all of the items in an item stack.
   * @type {number}
   */
  get totalWeight() {
    return this.quantity * this.weight.value;
  }

  /* -------------------------------------------- */

  /**
   * Field specifications for physical items.
   * @type {object[]}
   */
  get physicalItemSheetFields() {
    return [{
      label: CONFIG.DND5E.itemRarity[this.rarity],
      value: this._source.rarity,
      requiresIdentification: true,
      field: this.schema.getField("rarity"),
      choices: CONFIG.DND5E.itemRarity,
      blank: "DND5E.Rarity",
      classes: "item-rarity"
    }];
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare physical item properties.
   */
  preparePhysicalData() {
    if ( !("gp" in CONFIG.DND5E.currencies) ) return;
    const { value, denomination } = this.price;
    const { conversion } = CONFIG.DND5E.currencies[denomination] ?? {};
    const { gp } = CONFIG.DND5E.currencies;
    if ( conversion ) {
      const multiplier = gp.conversion / conversion;
      this.price.valueInGP = Math.floor(value * multiplier);
    }
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    PhysicalItemTemplate.#migratePrice(source);
    PhysicalItemTemplate.#migrateRarity(source);
    PhysicalItemTemplate.#migrateWeight(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the item's price from a single field to an object with currency.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migratePrice(source) {
    if ( !("price" in source) || foundry.utils.getType(source.price) === "Object" ) return;
    source.price = {
      value: Number.isNumeric(source.price) ? Number(source.price) : 0,
      denomination: "gp"
    };
  }

  /* -------------------------------------------- */

  /**
   * Migrate the item's rarity from freeform string to enum value.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateRarity(source) {
    if ( !("rarity" in source) || CONFIG.DND5E.itemRarity[source.rarity] ) return;
    source.rarity = Object.keys(CONFIG.DND5E.itemRarity).find(key =>
      CONFIG.DND5E.itemRarity[key].toLowerCase() === source.rarity.toLowerCase()
    ) ?? "";
  }

  /* -------------------------------------------- */

  /**
   * Migrate the item's weight from a single field to an object with units & convert null weights to 0.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateWeight(source) {
    if ( !("weight" in source) || (foundry.utils.getType(source.weight) === "Object") ) return;
    source.weight = {
      value: Number.isNumeric(source.weight) ? Number(source.weight) : 0,
      units: defaultUnits("weight")
    };
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Trigger a render on all sheets for items within which this item is contained.
   * @param {object} [options={}]
   * @param {object} [options.rendering]        Additional rendering options.
   * @param {string} [options.formerContainer]  UUID of the former container if this item was moved.
   * @protected
   */
  async _renderContainers({ formerContainer, ...rendering }={}) {
    // Render this item's container & any containers it is within
    const parentContainers = await this.allContainers();
    parentContainers.forEach(c => c.sheet?.render(false, rendering));

    // Render the actor sheet, compendium, or sidebar
    if ( this.parent.isEmbedded ) this.parent.actor.sheet?.render(false, rendering);
    else if ( this.parent.pack ) game.packs.get(this.parent.pack).apps.forEach(a => a.render(false, rendering));
    else ui.items.render(false, rendering);

    // Render former container if it was moved between containers
    if ( formerContainer ) {
      const former = await fromUuid(formerContainer);
      former.render(false, rendering);
      former.system._renderContainers(rendering);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( await super._preUpdate(changed, options, user) === false ) return false;
    if ( foundry.utils.hasProperty(changed, "system.container") ) {
      options.formerContainer = (await this.parent.container)?.uuid;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( options.render !== false ) this._renderContainers();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( options.render !== false ) this._renderContainers({ formerContainer: options.formerContainer });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( options.render !== false ) this._renderContainers();
  }

  /* -------------------------------------------- */
  /*  Helper Methods                              */
  /* -------------------------------------------- */

  /**
   * All of the containers this item is within up to the parent actor or collection.
   * @returns {Promise<Item5e[]>}
   */
  async allContainers() {
    let item = this.parent;
    let container;
    let depth = 0;
    const containers = [];
    while ( (container = await item.container) && (depth < PhysicalItemTemplate.MAX_DEPTH) ) {
      containers.push(container);
      item = container;
      depth++;
    }
    return containers;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the total weight and return it in specific units.
   * @param {string} units  Units in which the weight should be returned.
   * @returns {number|Promise<number>}
   */
  totalWeightIn(units) {
    const weight = this.totalWeight;
    if ( weight instanceof Promise ) return weight.then(w => convertWeight(w, this.weight.units, units));
    return convertWeight(weight, this.weight.units, units);
  }
}

const { NumberField: NumberField$s, SchemaField: SchemaField$v, SetField: SetField$l, StringField: StringField$F } = foundry.data.fields;

/**
 * Data definition for Container items.
 * @mixes ItemDescriptionTemplate
 * @mixes IdentifiableTemplate
 * @mixes PhysicalItemTemplate
 * @mixes EquippableItemTemplate
 * @mixes CurrencyTemplate
 *
 * @property {object} capacity              Information on container's carrying capacity.
 * @property {number} capacity.count        Number of items that can be stored within the container.
 * @property {object} capacity.volume
 * @property {string} capacity.volume.units  Units used to measure volume capacity.
 * @property {number} capacity.volume.value  Amount of volume that can be stored.
 * @property {object} capacity.weight
 * @property {string} capacity.weight.units  Units used to measure weight capacity.
 * @property {number} capacity.weight.value  Amount of weight that can be stored.
 * @property {Set<string>} properties       Container properties.
 */
class ContainerData extends ItemDataModel.mixin(
  ItemDescriptionTemplate, IdentifiableTemplate, PhysicalItemTemplate, EquippableItemTemplate, CurrencyTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CONTAINER", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      capacity: new SchemaField$v({
        count: new NumberField$s({ min: 0, integer: true }),
        volume: new SchemaField$v({
          value: new NumberField$s({ min: 0 }),
          units: new StringField$F({ initial: () => defaultUnits("volume") })
        }),
        weight: new SchemaField$v({
          value: new NumberField$s({ min: 0 }),
          units: new StringField$F({ initial: () => defaultUnits("weight") })
        })
      }),
      properties: new SetField$l(new StringField$F()),
      quantity: new NumberField$s({ min: 1, max: 1 })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    inventoryItem: true,
    inventoryOrder: 500
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("container")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ContainerData.#migrateCapacity(source);
    ContainerData.#migrateQuantity(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the weightless property into `properties`.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static _migrateWeightlessData(source) {
    if ( foundry.utils.getProperty(source, "system.capacity.weightless") === true ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedProperties", ["weightlessContents"]);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate capacity to support multiple fields and units.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateCapacity(source) {
    if ( !source.capacity || !source.capacity.type || !source.capacity.value || (source.capacity.count !== undefined)
      || (foundry.utils.getType(source.capacity.weight) === "Object") ) return;
    if ( source.capacity.type === "weight" ) {
      source.capacity.weight ??= {};
      source.capacity.weight.value = source.capacity.value;
    } else if ( source.capacity.type === "item" ) {
      source.capacity.count = source.capacity.value;
    }
    delete source.capacity.type;
    delete source.capacity.value;
  }

  /* -------------------------------------------- */

  /**
   * Force quantity to always be 1.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateQuantity(source) {
    source.quantity = 1;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    const data = super.getFavoriteData();
    const capacity = await this.computeCapacity();
    if ( Number.isFinite(capacity.max) ) return foundry.utils.mergeObject(await data, { uses: capacity });
    return await data;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: context.itemType },
      ...this.physicalItemSheetFields
    ];
    context.parts = ["dnd5e.details-container"];
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Get all of the items contained in this container. A promise if item is within a compendium.
   * @type {Collection<Item5e>|Promise<Collection<Item5e>>}
   */
  get contents() {
    if ( !this.parent ) return new foundry.utils.Collection();

    // If in a compendium, fetch using getDocuments and return a promise
    if ( this.parent.pack && !this.parent.isEmbedded ) {
      const pack = game.packs.get(this.parent.pack);
      return pack.getDocuments({system: { container: this.parent.id }}).then(d =>
        new foundry.utils.Collection(d.map(d => [d.id, d]))
      );
    }

    // Otherwise use local document collection
    return (this.parent.isEmbedded ? this.parent.actor.items : game.items).reduce((collection, item) => {
      if ( item.system.container === this.parent.id ) collection.set(item.id, item);
      return collection;
    }, new foundry.utils.Collection());
  }

  /* -------------------------------------------- */

  /**
   * Get all of the items in this container and any sub-containers. A promise if item is within a compendium.
   * @type {Collection<Item5e>|Promise<Collection<Item5e>>}
   */
  get allContainedItems() {
    if ( !this.parent ) return new foundry.utils.Collection();
    if ( this.parent.pack ) return this.#allContainedItems();

    return this.contents.reduce((collection, item) => {
      collection.set(item.id, item);
      if ( item.type === "container" ) item.system.allContainedItems.forEach(i => collection.set(i.id, i));
      return collection;
    }, new foundry.utils.Collection());
  }

  /**
   * Asynchronous helper method for fetching all contained items from a compendium.
   * @returns {Promise<Collection<Item5e>>}
   * @private
   */
  async #allContainedItems() {
    return (await this.contents).reduce(async (promise, item) => {
      const collection = await promise;
      collection.set(item.id, item);
      if ( item.type === "container" ) (await item.system.allContainedItems).forEach(i => collection.set(i.id, i));
      return collection;
    }, new foundry.utils.Collection());
  }

  /* -------------------------------------------- */

  /**
   * Fetch a specific contained item.
   * @param {string} id                 ID of the item to fetch.
   * @returns {Item5e|Promise<Item5e>}  Item if found.
   */
  getContainedItem(id) {
    if ( this.parent?.isEmbedded ) return this.parent.actor.items.get(id);
    if ( this.parent?.pack ) return game.packs.get(this.parent.pack)?.getDocument(id);
    return game.items.get(id);
  }

  /* -------------------------------------------- */

  /**
   * Number of items contained in this container including items in sub-containers. Result is a promise if item
   * is within a compendium.
   * @type {number|Promise<number>}
   */
  get contentsCount() {
    const reducer = (count, item) => count + item.system.quantity;
    const items = this.allContainedItems;
    if ( items instanceof Promise ) return items.then(items => items.reduce(reducer, 0));
    return items.reduce(reducer, 0);
  }

  /* -------------------------------------------- */

  /**
   * Weight of the items in this container. Result is a promise if item is within a compendium.
   * @type {number|Promise<number>}
   */
  get contentsWeight() {
    if ( this.parent?.pack && !this.parent?.isEmbedded ) return this.#contentsWeight();
    return this.contents.reduce((weight, item) =>
      weight + item.system.totalWeightIn(this.weight.units), this.currencyWeight
    );
  }

  /**
   * Asynchronous helper method for calculating the weight of items in a compendium.
   * @returns {Promise<number>}
   */
  async #contentsWeight() {
    const contents = await this.contents;
    return contents.reduce(async (weight, item) =>
      await weight + await item.system.totalWeightIn(this.weight.units), this.currencyWeight
    );
  }

  /* -------------------------------------------- */

  /**
   * The weight of this container with all of its contents. Result is a promise if item is within a compendium.
   * @type {number|Promise<number>}
   */
  get totalWeight() {
    if ( this.properties.has("weightlessContents") ) return this.weight.value;
    const containedWeight = this.contentsWeight;
    if ( containedWeight instanceof Promise ) return containedWeight.then(c => this.weight.value + c);
    return this.weight.value + containedWeight;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} Item5eCapacityDescriptor
   * @property {number} value  The current total weight or number of items in the container.
   * @property {number} max    The maximum total weight or number of items in the container.
   * @property {number} pct    The percentage of total capacity.
   * @property {string} units  The units label.
   */

  /**
   * Compute capacity information for this container.
   * @returns {Promise<Item5eCapacityDescriptor>}
   */
  async computeCapacity() {
    const context = { max: Infinity, value: 0 };
    if ( this.capacity.count ) {
      context.value = await this.contentsCount;
      context.max = this.capacity.count;
      context.units = game.i18n.localize("DND5E.Items");
    } else if ( this.capacity.weight.value ) {
      context.value = await this.contentsWeight;
      context.max = this.capacity.weight.value;
      context.units = CONFIG.DND5E.weightUnits[this.capacity.weight.units]?.label ?? "";
    }
    context.value = context.value.toNearest(0.1);
    context.pct = Math.clamp(context.max ? (context.value / context.max) * 100 : 0, 0, 100);
    return context;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdate(changed, options, userId) {
    // Keep contents folder synchronized with container
    if ( (game.user.id === userId) && foundry.utils.hasProperty(changed, "folder") ) {
      const contents = await this.contents;
      await Item.updateDocuments(contents.map(c => ({ _id: c.id, folder: changed.folder })), {
        parent: this.parent.parent, pack: this.parent.pack, ...options, render: false
      });
    }

    super._onUpdate(changed, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDelete(options, userId) {
    super._onDelete(options, userId);
    if ( (userId !== game.user.id) || !options.deleteContents ) return;

    // Delete a container's contents when it is deleted
    const contents = await this.allContainedItems;
    if ( contents?.size ) await Item.deleteDocuments(Array.from(contents.map(i => i.id)), {
      pack: this.parent.pack,
      parent: this.parent.parent
    });
  }
}

/**
 * Data model template for items with activities.
 *
 * @property {ActivityCollection} activities  Activities on this item.
 * @property {UsesData} uses                  Item's limited uses & recovery.
 * @mixin
 */
class ActivitiesTemplate extends SystemDataModel {

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.USES"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      activities: new ActivitiesField(),
      uses: new UsesField()
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Which ability score modifier is used by this item?
   * @type {string|null}
   */
  get abilityMod() {
    return this._typeAbilityMod || null;
  }

  /**
   * Default ability key defined for this type.
   * @type {string|null}
   * @internal
   */
  get _typeAbilityMod() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Enchantments that have been applied by this item.
   * @type {ActiveEffect5e[]}
   */
  get appliedEnchantments() {
    return dnd5e.registry.enchantments.applied(this.parent.uuid);
  }

  /* -------------------------------------------- */

  /**
   * Value on a d20 die needed to roll a critical hit with an attack from this item.
   * @type {number|null}
   */
  get criticalThreshold() {
    return this._typeCriticalThreshold ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement an attack roll as part of its usage?
   * @type {boolean}
   */
  get hasAttack() {
    return !!this.activities.getByType("attack").length;
  }

  /* -------------------------------------------- */

  /**
   * Is this Item limited in its ability to be used by charges or by recharge?
   * @type {boolean}
   */
  get hasLimitedUses() {
    return !!this._source.uses.max || !!this.uses.max;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a saving throw as part of its usage?
   * @type {boolean}
   */
  get hasSave() {
    return !!this.activities.getByType("save").length;
  }

  /* -------------------------------------------- */

  /**
   * Does this Item implement summoning as part of its usage?
   * @type {boolean}
   */
  get hasSummoning() {
    const activity = this.activities.getByType("summon")[0];
    return activity && activity.profiles.length > 0;
  }

  /* -------------------------------------------- */

  /**
   * Is this Item an activatable item?
   * @type {boolean}
   */
  get isActive() {
    return this.activities.size > 0;
  }

  /* -------------------------------------------- */

  /**
   * Can this item enchant other items?
   * @type {boolean}
   */
  get isEnchantment() {
    return !!this.activities.getByType("enchant").length;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item provide an amount of healing instead of conventional damage?
   * @type {boolean}
   */
  get isHealing() {
    return !!this.activities.getByType("heal").length;
  }

  /* -------------------------------------------- */

  /**
   * Creatures summoned by this item.
   * @type {Actor5e[]}
   */
  get summonedCreatures() {
    if ( !this.actor ) return [];
    return this.activities.getByType("summon").map(a => a.summonedCreatures).flat();
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /**
   * Migrate the uses data structure from before activities.
   * @param {object} source  Candidate source data to migrate.
   */
  static migrateActivities(source) {
    ActivitiesTemplate.#migrateUses(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the uses to the new data structure.
   * @param {object} source  Candidate source data to migrate.
   */
  static #migrateUses(source) {
    // Remove any old ternary operators from uses to prevent errors
    if ( source.uses?.max?.includes?.(" ? ") ) source.uses.max = "";
    for ( const activity of Object.values(source.activities ?? {}) ) {
      if ( activity?.uses?.max?.includes?.(" ? ") ) activity.uses.max = "";
    }

    if ( Array.isArray(source.uses?.recovery) ) return;

    const charged = source.recharge?.charged;
    if ( (source.recharge?.value !== null) && (charged !== undefined) && !source.uses?.max ) {
      source.uses ??= {};
      source.uses.spent = charged ? 0 : 1;
      source.uses.max = "1";
    }

    if ( foundry.utils.getType(source.uses?.recovery) !== "string" ) return;

    // If period is charges, set the recovery type to `formula`
    if ( source.uses?.per === "charges" ) {
      if ( source.uses.recovery ) {
        source.uses.recovery = [{ period: "lr", type: "formula", formula: source.uses.recovery }];
      } else {
        delete source.uses.recovery;
      }
    }

    // If period is not blank, set an appropriate recovery type
    else if ( source.uses?.per ) {
      if ( CONFIG.DND5E.limitedUsePeriods[source.uses.per]?.formula && source.uses.recovery ) {
        source.uses.recovery = [{ period: source.uses.per, type: "formula", formula: source.uses.recovery }];
      }
      else source.uses.recovery = [{ period: source.uses.per, type: "recoverAll" }];
    }

    // Otherwise, check to see if recharge is set
    else if ( source.recharge?.value ) {
      source.uses.recovery = [{ period: "recharge", formula: source.recharge.value }];
    }

    // Prevent a string value for uses recovery from being cleaned into a default recovery entry
    else if ( source.uses?.recovery === "" ) {
      delete source.uses.recovery;
    }
  }

  /* -------------------------------------------- */

  /**
   * Modify data before initialization to create initial activity if necessary.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static initializeActivities(source) {
    if ( this.#shouldCreateInitialActivity(source) ) this.#createInitialActivity(source);
    const uses = source.system?.uses ?? {};
    if ( source._id && source.type && ("value" in uses) && uses.max ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedUses", uses.value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Method to determine whether the activity creation migration should be performed. This migration should only be
   * performed on whole item data rather than partial updates, so check to ensure all of the necessary data is present.
   * @param {object} source  The candidate source data from which the model will be constructed.
   * @returns {boolean}
   */
  static #shouldCreateInitialActivity(source) {
    // Do not attempt to migrate partial source data.
    if ( !source._id || !source.type || !source.system || !source.effects ) return false;

    // If item doesn't have an action type or activation, then it doesn't need an activity
    if ( !source.system.actionType && !source.system.activation?.type
      && (source.type !== "tool") ) return false;

    // If item was updated after `4.0.1`, it shouldn't need the migration
    if ( !foundry.utils.isNewerVersion("4.0.1", source._stats?.systemVersion ?? "0.0.0") ) return false;

    // If the initial activity has already been created, no reason to create it again
    if ( !foundry.utils.isEmpty(source.system.activities) ) return false;

    return true;
  }

  /* -------------------------------------------- */

  /**
   * Migrate data from ActionTemplate and ActivatedEffectTemplate into a newly created activity.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #createInitialActivity(source) {
    let type = {
      mwak: "attack",
      rwak: "attack",
      msak: "attack",
      rsak: "attack",
      abil: "check",
      save: "save",
      ench: "enchant",
      summ: "summon",
      heal: "heal"
    }[source.system.actionType] ?? "utility";
    if ( (type === "utility") && source.system.damage?.parts?.length ) type = "damage";
    if ( source.type === "tool" ) type = "check";

    const cls = CONFIG.DND5E.activityTypes[type].documentClass;
    cls.createInitialActivity(source);

    if ( (type !== "save") && source.system.save?.ability ) {
      CONFIG.DND5E.activityTypes.save.documentClass.createInitialActivity(source, { offset: 1 });
    }
    if ( (source.type !== "weapon") && source.system.damage?.versatile ) {
      CONFIG.DND5E.activityTypes.damage.documentClass.createInitialActivity(source, { offset: 2, versatile: true });
    }
    if ( (type !== "utility") && source.system.formula ) {
      CONFIG.DND5E.activityTypes.utility.documentClass.createInitialActivity(source, { offset: 3 });
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare final data for the activities & uses.
   * @param {object} rollData
   */
  prepareFinalActivityData(rollData) {
    const labels = this.parent.labels;
    UsesField.prepareData.call(this, rollData, labels);
    for ( const activity of this.activities ) activity.prepareFinalData();
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve information on available uses for display.
   * @returns {{value: number, max: number, name: string}}
   */
  getUsesData() {
    return { value: this.uses.value, max: this.uses.max, name: "system.uses.value" };
  }

  /* -------------------------------------------- */

  /**
   * Perform any item & activity uses recovery.
   * @param {string[]} periods  Recovery periods to check.
   * @param {object} rollData   Roll data to use when evaluating recover formulas.
   * @returns {Promise<{ updates: object, rolls: BasicRoll[] }>}
   */
  async recoverUses(periods, rollData) {
    const updates = {};
    const rolls = [];
    const autoRecharge = game.settings.get("dnd5e", "autoRecharge");
    const shouldRecharge = periods.includes("turnStart") && (this.parent.actor.type === "npc")
      && (autoRecharge !== "no");
    const recharge = async doc => {
      const config = { apply: false };
      const message = { create: autoRecharge !== "silent" };
      const result = await UsesField.rollRecharge.call(doc, config, {}, message);
      if ( result ) {
        if ( doc instanceof Item ) foundry.utils.mergeObject(updates, result.updates);
        else foundry.utils.mergeObject(updates, { [`system.activities.${doc.id}`]: result.updates });
        rolls.push(...result.rolls);
      }
    };

    const result = await UsesField.recoverUses.call(this, periods, rollData);
    if ( result ) {
      foundry.utils.mergeObject(updates, { "system.uses": result.updates });
      rolls.push(...result.rolls);
    }
    if ( shouldRecharge ) await recharge(this.parent);

    for ( const activity of this.activities ) {
      const result = await UsesField.recoverUses.call(activity, periods, rollData);
      if ( result ) {
        foundry.utils.mergeObject(updates, { [`system.activities.${activity.id}.uses`]: result.updates });
        rolls.push(...result.rolls);
      }
      if ( shouldRecharge ) await recharge(activity);
    }

    return { updates, rolls };
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * Perform any necessary actions when an item with activities is created.
   * @param {object} data     The initial data object provided to the document creation request.
   * @param {object} options  Additional options which modify the update request.
   * @param {string} userId   The id of the User requesting the document update.
   */
  async onCreateActivities(data, options, userId) {
    if ( (userId !== game.user.id) || !this.parent.isEmbedded ) return;

    // If item has any Cast activities, create locally cached copies of the spells
    const spells = (await Promise.all(
      this.activities.getByType("cast").map(a => !a.cachedSpell && a.getCachedSpellData())
    )).filter(_ => _);
    if ( spells.length ) this.parent.actor.createEmbeddedDocuments("Item", spells);
  }

  /* -------------------------------------------- */

  /**
   * Prepare any item or actor changes based on activity changes.
   * @param {object} changed  The differential data that is changed relative to the document's prior values.
   * @param {object} options  Additional options which modify the update request.
   * @param {User} user       The User requesting the document update.
   */
  async preUpdateActivities(changed, options, user) {
    if ( !foundry.utils.hasProperty(changed, "system.activities") ) return;

    // Track changes to rider activities & effects and store in item flags
    const cloneChanges = foundry.utils.deepClone(changed);
    const riders = this.parent.clone(cloneChanges).system.activities.getByType("enchant").reduce((riders, a) => {
      a.effects.forEach(e => {
        e.riders.activity.forEach(activity => riders.activity.add(activity));
        e.riders.effect.forEach(effect => riders.effect.add(effect));
      });
      return riders;
    }, { activity: new Set(), effect: new Set() });
    foundry.utils.setProperty(changed, "flags.dnd5e.riders", {
      activity: Array.from(riders.activity), effect: Array.from(riders.effect)
    });

    if ( !this.parent.isEmbedded ) return;

    // Track changes to cached spells on cast activities
    const removed = Object.entries(changed.system?.activities ?? {}).map(([key, data]) => {
      if ( key.startsWith("-=") ) {
        const id = key.replace("-=", "");
        return this.activities.get(id).cachedSpell?.id;
      } else if ( foundry.utils.hasProperty(data, "spell.uuid") ) {
        return this.activities.get(key)?.cachedSpell?.id;
      }
      return null;
    }).filter(_ => _);
    if ( removed.length ) foundry.utils.setProperty(options, "dnd5e.removedCachedItems", removed);
  }

  /* -------------------------------------------- */

  /**
   * Perform any additional updates when an item with activities is updated.
   * @param {object} changed  The differential data that is changed relative to the document's prior values.
   * @param {object} options  Additional options which modify the update request.
   * @param {string} userId   The id of the User requesting the document update.
   */
  async onUpdateActivities(changed, options, userId) {
    if ( (userId !== game.user.id) || !this.parent.isEmbedded
      || !foundry.utils.hasProperty(changed, "system.activities") ) return;

    // If any Cast activities were removed, or their spells changed, remove old cached spells
    if ( options.dnd5e?.removedCachedItems ) {
      await this.parent.actor.deleteEmbeddedDocuments("Item", options.dnd5e.removedCachedItems);
    }

    // Create any new cached spells & update existing ones as necessary
    const cachedInserts = [];
    for ( const id of Object.keys(changed.system.activities) ) {
      const activity = this.activities.get(id);
      if ( !(activity instanceof CastActivity) ) continue;
      const existingSpell = activity.cachedSpell;
      if ( existingSpell ) {
        const enchantment = existingSpell.effects.get(CastActivity.ENCHANTMENT_ID);
        await enchantment.update({ changes: activity.getSpellChanges() });
      } else {
        const cached = await activity.getCachedSpellData();
        if ( cached ) cachedInserts.push(cached);
      }
    }
    if ( cachedInserts.length ) await this.parent.actor.createEmbeddedDocuments("Item", cachedInserts);
  }

  /* -------------------------------------------- */

  /**
   * Perform any necessary cleanup when an item with activities is deleted.
   * @param {object} options  Additional options which modify the deletion request.
   * @param {string} userId   The id of the User requesting the document update.
   */
  onDeleteActivities(options, userId) {
    if ( (userId !== game.user.id) || !this.parent.isEmbedded ) return;

    // If item has any Cast activities, clean up any cached spells
    const spellIds = this.activities.getByType("cast").map(a => a.cachedSpell?.id).filter(_ => _);
    if ( spellIds.length ) this.parent.actor.deleteEmbeddedDocuments("Item", spellIds);
  }

  /* -------------------------------------------- */
  /*  Shims                                       */
  /* -------------------------------------------- */

  /**
   * Apply shims for data removed from ActionTemplate & ActivatedEffectTemplate.
   * @this {ItemDataModel}
   */
  static _applyActivityShims() {
    const shim = (template, property, get) => {
      if ( property in this ) return;
      Object.defineProperty(this, property, {
        get: () => {
          foundry.utils.logCompatibilityWarning(
            `The \`${property}\` property on \`${template}\` has been deprecated.`,
            { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
          );
          return get();
        },
        configurable: true,
        enumerable: false
      });
    };
    const addShims = (template, shims) => Object.entries(shims).forEach(([key, method]) => shim(template, key, method));
    const firstActivity = this.activities.contents[0] ?? {};

    addShims("ActionTemplate", {
      ability: () => firstActivity.ability ?? null,
      actionType: () => firstActivity.actionType ?? "",
      attack: () => {
        const activity = this.activities.getByType("attack")[0] ?? {};
        return {
          bonus: activity.attack?.bonus ?? "",
          flat: activity.attack?.flat ?? false
        };
      },
      chatFlavor: () => firstActivity.description?.chatFlavor ?? "",
      critical: () => {
        const activity = this.activities.getByType("attack")[0] || this.activities.getByType("damage")[0];
        return {
          threshold: activity?.attack?.critical?.threshold ?? null,
          damage: activity?.damage?.critical?.bonus ?? ""
        };
      },
      damage: () => {
        const activity = this.activities.getByType("attack")[0] || this.activities.getByType("damage")[0]
          || this.activities.getByType("save")[0];
        return {
          parts: activity?.damage.parts.map(d => ([d.formula, d.types.first() ?? ""])) ?? [],
          versatile: ""
        };
      },
      enchantment: () => this.activities.getByType("enchant")[0],
      formula: () => this.activities.getByType("utility")[0]?.roll?.formula ?? "",
      hasAbilityCheck: () => false,
      hasDamage: () => !!this.activities.find(a => a.damage?.parts?.length),
      isVersatile: () => this.properties?.has("ver"),
      save: () => {
        const activity = this.activities.getByType("save")[0] ?? {};
        return {
          ability: activity.ability ?? null,
          dc: activity.save?.dc?.formula ?? null,
          scaling: activity.save?.dc?.calculation ?? ""
        };
      },
      summons: () => this.activities.getByType("summon")[0]
    });

    addShims("ActivatedEffectTemplate", {
      activatedEffectCardProperties: () => [
        this.parent.labels.activation,
        this.parent.labels.target,
        this.parent.labels.range,
        this.parent.labels.duration
      ],
      activation: () => {
        const activation = firstActivity.activation ?? {};
        return {
          type: activation.type ?? "",
          cost: activation.value ?? null,
          condition: activation.condition ?? ""
        };
      },
      consume: () => {
        const consumption = firstActivity.consumption ?? {};
        const target = consumption.targets?.[0] ?? {};
        return {
          type: target.type ?? "",
          target: target.target ?? "",
          amount: target.value ?? 1,
          scale: consumption.scaling?.allowed ?? false
        };
      },
      duration: () => firstActivity.duration ?? { value: null, units: "" },
      hasAmmo: () => {
        const consume = this.consume;
        return this.isActive && !!consume.target && !!consume.type && this.hasAttack && (consume.type === "ammo");
      },
      hasAreaTarget: () => this.isActive && this.target.template?.type,
      hasIndividualTarget: () => this.isActive && this.target.affects?.type,
      hasResource: () => this.isActive && !!this.consume.target && !!this.consume.type && !this.hasAttack,
      hasScalarDuration: () => this.duration.units in CONFIG.DND5E.scalarTimePeriods,
      hasScalarRange: () => this.range.units in CONFIG.DND5E.movementUnits,
      hasScalarTarget: () => this.target.template?.type || ![null, "", "self"].includes(this.target.affects?.type),
      hasTarget: () => this.isActive && (this.target.template?.type || this.target.affects?.type),
      range: () => firstActivity.range ?? { value: null, type: "" },
      target: () => {
        const target = firstActivity.target ?? {};
        return {
          value: target.affects?.count || target.template?.size || "",
          width: target.template?.width ?? "",
          units: target.template?.units ?? "",
          type: target.affects?.type ?? target.template?.type,
          prompt: target.prompt ?? true
        };
      }
    });
  }
}

/**
 * Data model template with item type, subtype and baseItem.
 *
 * @property {object} type                      Standardized item type object.
 * @property {string} type.value                Category to which this item belongs.
 * @property {string} type.subtype              Item subtype according to its category.
 * @property {string} type.baseItem             Item this one is based on.
 * @mixin
 */
class ItemTypeTemplate extends SystemDataModel {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Item categories used to populate `system.type.value`.
   * @type {Record<string, string>}
   */
  static get itemCategories() {
    return {};
  }

  get itemCategories() {
    return this.constructor.itemCategories();
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ItemTypeTemplate.#migrateType(source);
  }

  /* -------------------------------------------- */

  /**
   * Convert old types into the new standard.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateType(source) {
    if ( foundry.utils.getType(source.type) === "Object" ) return;
    const oldType = source.consumableType ?? source.armor?.type ?? source.toolType ?? source.weaponType;
    if ( (oldType !== null) && (oldType !== undefined) ) foundry.utils.setProperty(source, "type.value", oldType);
    if ( "baseItem" in source ) foundry.utils.setProperty(source, "type.baseItem", source.baseItem);
  }
}

const { BooleanField: BooleanField$m, NumberField: NumberField$r, SchemaField: SchemaField$u, StringField: StringField$E } = foundry.data.fields;

/**
 * Data model template for equipment that can be mounted on a vehicle.
 *
 * @property {number} cover               Amount of cover this item affords to its crew on a vehicle.
 * @property {boolean} crewed             Is this equipment currently crewed?
 * @property {object} hp
 * @property {number} hp.value            Current hit point value.
 * @property {number} hp.max              Max hit points.
 * @property {number} hp.dt               Damage threshold.
 * @property {string} hp.conditions       Conditions that are triggered when this equipment takes damage.
 * @property {object} speed
 * @property {string} speed.conditions    Conditions that may affect item's speed.
 * @property {number} speed.value         Speed granted by this piece of equipment measured in feet or meters
 *                                        depending on system setting.
 * @mixin
 */
class MountableTemplate extends SystemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      cover: new NumberField$r({ min: 0, max: 1 }),
      crewed: new BooleanField$m(),
      hp: new SchemaField$u({
        conditions: new StringField$E(),
        dt: new NumberField$r({ integer: true, min: 0 }),
        max: new NumberField$r({ integer: true, min: 0 }),
        value: new NumberField$r({ integer: true, min: 0 })
      }, { required: false, initial: undefined }),
      speed: new SchemaField$u({
        conditions: new StringField$E(),
        value: new NumberField$r({ min: 0 })
      }, { required: false, initial: undefined })
    };
  }
}

/**
 * A field for storing Item type data.
 *
 * @param {object} [options={}]                   Options to configure this field's behavior.
 * @param {string} [options.value]                An initial value for the Item's type.
 * @param {string|boolean} [options.subtype]      An initial value for the Item's subtype, or false to exclude it.
 * @param {string|boolean} [options.baseItem]     An initial value for the Item's baseItem, or false to exclude it.
 * @param {DataFieldOptions} [schemaOptions={}]   Options forwarded to the SchemaField.
 */
class ItemTypeField extends foundry.data.fields.SchemaField {
  constructor(options={}, schemaOptions={}) {
    const fields = {
      value: new foundry.data.fields.StringField({
        required: true, blank: true, initial: options.value ?? "", label: "DND5E.Type"
      }),
      subtype: new foundry.data.fields.StringField({
        required: true, blank: true, initial: options.subtype ?? "", label: "DND5E.Subtype"
      }),
      baseItem: new foundry.data.fields.StringField({
        required: true, blank: true, initial: options.baseItem ?? "", label: "DND5E.BaseItem"
      })
    };
    if ( options.subtype === false ) delete fields.subtype;
    if ( options.baseItem === false ) delete fields.baseItem;
    super(fields, schemaOptions);
  }
}

const { NumberField: NumberField$q, SchemaField: SchemaField$t, SetField: SetField$k, StringField: StringField$D } = foundry.data.fields;

/**
 * Data definition for Equipment items.
 * @mixes ActivitiesTemplate
 * @mixes ItemDescriptionTemplate
 * @mixes ItemTypeTemplate
 * @mixes IdentifiableTemplate
 * @mixes PhysicalItemTemplate
 * @mixes EquippableItemTemplate
 * @mixes MountableTemplate
 *
 * @property {object} armor               Armor details and equipment type information.
 * @property {number} armor.value         Base armor class or shield bonus.
 * @property {number} armor.dex           Maximum dex bonus added to armor class.
 * @property {number} armor.magicalBonus  Bonus added to AC from the armor's magical nature.
 * @property {number} strength            Minimum strength required to use a piece of armor.
 * @property {number} proficient          Does the owner have proficiency in this piece of equipment?
 */
class EquipmentData extends ItemDataModel.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate, MountableTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.VEHICLE.MOUNTABLE", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      type: new ItemTypeField({ subtype: false }, { label: "DND5E.ItemEquipmentType" }),
      armor: new SchemaField$t({
        value: new NumberField$q({ required: true, integer: true, min: 0, label: "DND5E.ArmorClass" }),
        magicalBonus: new NumberField$q({ min: 0, integer: true, label: "DND5E.MagicalBonus" }),
        dex: new NumberField$q({ required: true, integer: true, label: "DND5E.ItemEquipmentDexMod" })
      }),
      properties: new SetField$k(new StringField$D(), { label: "DND5E.ItemEquipmentProperties" }),
      strength: new NumberField$q({ required: true, integer: true, min: 0, label: "DND5E.ItemRequiredStr" }),
      proficient: new NumberField$q({
        required: true, min: 0, max: 1, integer: true, initial: null, label: "DND5E.ProficiencyLevel"
      })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    inventoryItem: true,
    inventoryOrder: 200
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemEquipmentType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.equipmentTypes,
          keyPath: "system.type.value"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("equipment")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    EquipmentData.#migrateArmor(source);
    EquipmentData.#migrateType(source);
    EquipmentData.#migrateStrength(source);
    EquipmentData.#migrateProficient(source);
  }

  /* -------------------------------------------- */

  /**
   * Apply migrations to the armor field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateArmor(source) {
    if ( !("armor" in source) ) return;
    source.armor ??= {};
    if ( (typeof source.armor.dex === "string") ) {
      const dex = source.armor.dex;
      if ( dex === "" ) source.armor.dex = null;
      else if ( Number.isNumeric(dex) ) source.armor.dex = Number(dex);
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply migrations to the type field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateType(source) {
    if ( !("type" in source) ) return;
    if ( source.type.value === "bonus" ) source.type.value = "trinket";
  }

  /* -------------------------------------------- */

  /**
   * Ensure blank strength values are migrated to null, and string values are converted to numbers.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateStrength(source) {
    if ( typeof source.strength !== "string" ) return;
    if ( source.strength === "" ) source.strength = null;
    if ( Number.isNumeric(source.strength) ) source.strength = Number(source.strength);
  }

  /* -------------------------------------------- */

  /**
   * Migrates stealth disadvantage boolean to properties.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static _migrateStealth(source) {
    if ( foundry.utils.getProperty(source, "system.stealth") === true ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedProperties", ["stealthDisadvantage"]);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the proficient field to convert boolean values.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateProficient(source) {
    if ( typeof source.proficient === "boolean" ) source.proficient = Number(source.proficient);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();
    this.armor.base = this.armor.value = (this._source.armor.value ?? 0);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    ActivitiesTemplate._applyActivityShims.call(this);
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    if ( this.magicAvailable && this.armor.magicalBonus ) this.armor.value += this.armor.magicalBonus;
    this.type.label = CONFIG.DND5E.equipmentTypes[this.type.value]
      ?? game.i18n.localize(CONFIG.Item.typeLabels.equipment);
    this.type.identifier = this.type.value === "shield"
      ? CONFIG.DND5E.shieldIds[this.type.baseItem]
      : CONFIG.DND5E.armorIds[this.type.baseItem];

    const labels = this.parent.labels ??= {};
    labels.armor = this.armor.value ? `${this.armor.value} ${game.i18n.localize("DND5E.AC")}` : "";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.type.label, this.parent.labels.activation],
      uses: this.hasLimitedUses ? this.getUsesData() : null
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];
    if ( this.armor.value && (this.isArmor || (this.type.value === "shield")) ) {
      context.properties.active.shift();
      context.info = [{
        label: "DND5E.ArmorClass",
        classes: "info-lg",
        value: this.type.value === "shield" ? dnd5e.utils.formatModifier(this.armor.value) : this.armor.value
      }];
    }
    context.parts = ["dnd5e.details-equipment", "dnd5e.field-uses"];
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      (this.isArmor || this.isMountable) ? (this.parent.labels?.armor ?? null) : null,
      this.properties.has("stealthDisadvantage") ? game.i18n.localize("DND5E.Item.Property.StealthDisadvantage") : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    return [
      (this.isArmor || this.isMountable) ? (this.parent.labels?.armor ?? null) : null,
      this.properties.has("stealthDisadvantage") ? game.i18n.localize("DND5E.Item.Property.StealthDisadvantage") : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Is this Item any of the armor subtypes?
   * @type {boolean}
   */
  get isArmor() {
    return this.type.value in CONFIG.DND5E.armorTypes;
  }

  /* -------------------------------------------- */

  /**
   * Is this item a separate large object like a siege engine or vehicle component that is
   * usually mounted on fixtures rather than equipped, and has its own AC and HP?
   * @type {boolean}
   */
  get isMountable() {
    return this.type.value === "vehicle";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.equipmentTypes;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    if ( Number.isFinite(this.proficient) ) return this.proficient;
    const actor = this.parent.actor;
    if ( !actor ) return 0;
    if ( actor.type === "npc" ) return 1; // NPCs are always considered proficient with any armor in their stat block.
    const config = CONFIG.DND5E.armorProficienciesMap;
    const itemProf = config[this.type.value];
    const actorProfs = actor.system.traits?.armorProf?.value ?? new Set();
    const isProficient = (itemProf === true) || actorProfs.has(itemProf) || actorProfs.has(this.type.baseItem);
    return Number(isProficient);
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    await this.preCreateEquipped(data, options, user);

    // Set type as "Vehicle Equipment" if created directly on a vehicle
    if ( (this.parent.actor?.type === "vehicle") && !foundry.utils.hasProperty(data, "system.type.value") ) {
      this.updateSource({ "type.value": "vehicle" });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

const { BooleanField: BooleanField$l, NumberField: NumberField$p, SchemaField: SchemaField$s, SetField: SetField$j, StringField: StringField$C } = foundry.data.fields;

/**
 * Data definition for Spell items.
 * @mixes ActivitiesTemplate
 * @mixes ItemDescriptionTemplate
 *
 * @property {string} ability                    Override of default spellcasting ability.
 * @property {ActivationData} activation         Casting time & conditions.
 * @property {DurationData} duration             Duration of the spell effect.
 * @property {number} level                      Base level of the spell.
 * @property {object} materials                  Details on material components required for this spell.
 * @property {string} materials.value            Description of the material components required for casting.
 * @property {boolean} materials.consumed        Are these material components consumed during casting?
 * @property {number} materials.cost             GP cost for the required components.
 * @property {number} materials.supply           Quantity of this component available.
 * @property {object} preparation                Details on how this spell is prepared.
 * @property {string} preparation.mode           Spell preparation mode as defined in `DND5E.spellPreparationModes`.
 * @property {boolean} preparation.prepared      Is the spell currently prepared?
 * @property {Set<string>} properties            General components and tags for this spell.
 * @property {RangeData} range                   Range of the spell
 * @property {string} school                     Magical school to which this spell belongs.
 * @property {string} sourceClass                Associated spellcasting class when this spell is on an actor.
 * @property {TargetData} target                 Information on area and individual targets.
 */
class SpellData extends ItemDataModel.mixin(ActivitiesTemplate, ItemDescriptionTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = [
    "DND5E.ACTIVATION", "DND5E.DURATION", "DND5E.RANGE", "DND5E.SOURCE", "DND5E.TARGET"
  ];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      ability: new StringField$C({ label: "DND5E.SpellAbility" }),
      activation: new ActivationField(),
      duration: new DurationField(),
      level: new NumberField$p({ required: true, integer: true, initial: 1, min: 0, label: "DND5E.SpellLevel" }),
      materials: new SchemaField$s({
        value: new StringField$C({ required: true, label: "DND5E.SpellMaterialsDescription" }),
        consumed: new BooleanField$l({ required: true, label: "DND5E.SpellMaterialsConsumed" }),
        cost: new NumberField$p({ required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsCost" }),
        supply: new NumberField$p({ required: true, initial: 0, min: 0, label: "DND5E.SpellMaterialsSupply" })
      }, { label: "DND5E.SpellMaterials" }),
      preparation: new SchemaField$s({
        mode: new StringField$C({ required: true, initial: "prepared", label: "DND5E.SpellPreparation.Mode" }),
        prepared: new BooleanField$l({ required: true, label: "DND5E.SpellPrepared" })
      }, { label: "DND5E.SpellPreparation.Label" }),
      properties: new SetField$j(new StringField$C(), { label: "DND5E.SpellComponents" }),
      range: new RangeField(),
      school: new StringField$C({ required: true, label: "DND5E.SpellSchool" }),
      sourceClass: new StringField$C({ label: "DND5E.SpellSourceClass" }),
      target: new TargetField()
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["level", {
        label: "DND5E.Level",
        type: "range",
        config: {
          keyPath: "system.level",
          min: 0,
          max: Object.keys(CONFIG.DND5E.spellLevels).length - 1
        }
      }],
      ["school", {
        label: "DND5E.School",
        type: "set",
        config: {
          choices: CONFIG.DND5E.spellSchools,
          keyPath: "system.school"
        }
      }],
      ["spelllist", {
        label: "TYPES.JournalEntryPage.spells",
        type: "set",
        createFilter: (filters, value, def) => {
          let include = new Set();
          let exclude = new Set();
          for ( const [type, identifiers] of Object.entries(value ?? {}) ) {
            for ( const [identifier, v] of Object.entries(identifiers) ) {
              const list = dnd5e.registry.spellLists.forType(type, identifier);
              if ( !list || (v === 0) ) continue;
              if ( v === 1 ) include = include.union(list.uuids);
              else if ( v === -1 ) exclude = exclude.union(list.uuids);
            }
          }
          if ( include.size ) filters.push({ k: "uuid", o: "in", v: include });
          if ( exclude.size ) filters.push({ o: "NOT", v: { k: "uuid", o: "in", v: exclude } });
        },
        config: {
          choices: dnd5e.registry.spellLists.options.reduce((obj, entry) => {
            obj[`${entry.type}.${entry.value}`] = entry.label;
            return obj;
          }, {})
        }
      }],
      ["properties", this.compendiumBrowserPropertiesFilter("spell")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    SpellData.#migrateActivation(source);
    SpellData.#migrateTarget(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the component object to be 'properties' instead.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static _migrateComponentData(source) {
    const components = filteredKeys(source.system?.components ?? {});
    if ( components.length ) {
      foundry.utils.setProperty(source, "flags.dnd5e.migratedProperties", components);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate activation data.
   * Added in DnD5e 4.0.0.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateActivation(source) {
    if ( source.activation?.cost ) source.activation.value = source.activation.cost;
  }

  /* -------------------------------------------- */

  /**
   * Migrate target data.
   * Added in DnD5e 4.0.0.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateTarget(source) {
    if ( !("target" in source) ) return;
    source.target.affects ??= {};
    source.target.template ??= {};

    if ( "units" in source.target ) source.target.template.units = source.target.units;
    if ( "width" in source.target ) source.target.template.width = source.target.width;

    const type = source.target.type ?? source.target.template.type ?? source.target.affects.type;
    if ( type in CONFIG.DND5E.areaTargetTypes ) {
      if ( "type" in source.target ) source.target.template.type = type;
      if ( "value" in source.target ) source.target.template.size = source.target.value;
    } else if ( type in CONFIG.DND5E.individualTargetTypes ) {
      if ( "type" in source.target ) source.target.affects.type = type;
      if ( "value" in source.target ) source.target.affects.count = source.target.value;
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    ActivitiesTemplate._applyActivityShims.call(this);
    this._applySpellShims();
    super.prepareDerivedData();
    this.prepareDescriptionData();

    this.preparation.mode ||= "prepared";
    this.properties.add("mgc");
    this.duration.concentration = this.properties.has("concentration");

    const labels = this.parent.labels ??= {};
    labels.level = CONFIG.DND5E.spellLevels[this.level];
    labels.school = CONFIG.DND5E.spellSchools[this.school]?.label;
    if ( this.properties.has("material") ) labels.materials = this.materials.value;

    labels.components = this.properties.reduce((obj, c) => {
      const config = this.validProperties.has(c) ? CONFIG.DND5E.itemProperties[c] : null;
      if ( !config ) return obj;
      const { abbreviation: abbr, label, icon } = config;
      obj.all.push({ abbr, icon, tag: config.isTag });
      if ( config.isTag ) obj.tags.push(label);
      else obj.vsm.push(abbr);
      return obj;
    }, { all: [], vsm: [], tags: [] });
    labels.components.vsm = game.i18n.getListFormatter({ style: "narrow" }).format(labels.components.vsm);

    const uuid = this.parent._stats.compendiumSource ?? this.parent.uuid;
    Object.defineProperty(labels, "classes", {
      get() {
        return game.i18n.getListFormatter({ style: "narrow" }).format(
          Array.from(dnd5e.registry.spellLists.forSpell(uuid))
            .filter(list => list.metadata.type === "class")
            .map(list => list.name)
            .sort((lhs, rhs) => lhs.localeCompare(rhs, game.i18n.lang))
        );
      },
      configurable: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const labels = this.parent.labels ??= {};
    this.prepareFinalActivityData(rollData);
    ActivationField.prepareData.call(this, rollData, labels);
    DurationField.prepareData.call(this, rollData, labels);
    RangeField.prepareData.call(this, rollData, labels);
    TargetField.prepareData.call(this, rollData, labels);

    // Count preparations.
    const { mode, prepared } = this.preparation;
    const config = CONFIG.DND5E.spellPreparationModes[mode];
    const isPrepared = config?.prepares && (mode !== "always") && (this.level > 0) && prepared;
    if ( this.parent.isOwned && this.sourceClass && isPrepared ) {
      const sourceClass = this.parent.actor.spellcastingClasses[this.sourceClass];
      const sourceSubclass = sourceClass?.subclass;
      if ( sourceClass ) sourceClass.system.spellcasting.preparation.value++;
      if ( sourceSubclass ) sourceSubclass.system.spellcasting.preparation.value++;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getCardData(enrichmentOptions={}) {
    const context = await super.getCardData(enrichmentOptions);
    context.isSpell = true;
    context.subtitle = [this.parent.labels.level, CONFIG.DND5E.spellSchools[this.school]?.label].filterJoin(" &bull; ");
    const { activation, components, duration, range, target } = this.parent.labels;
    context.properties = [components?.vsm, activation, duration, range, target].filter(_ => _);
    if ( !this.properties.has("material") ) delete context.materials;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.parent.labels.components.vsm, this.parent.labels.activation],
      modifier: this.parent.labels.modifier,
      range: this.range,
      save: this.activities.getByType("save")[0]?.save
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    if ( this.parent.actor ) {
      const ability = CONFIG.DND5E.abilities[
        this.parent.actor.spellcastingClasses[this.sourceClass]?.spellcasting.ability
          ?? this.parent.actor.system.attributes?.spellcasting
      ]?.label?.toLowerCase();
      if ( ability ) context.defaultAbility = game.i18n.format("DND5E.DefaultSpecific", { default: ability });
      else context.defaultAbility = game.i18n.localize("DND5E.Default");
    }
    context.subtitles = [
      { label: context.labels.level },
      { label: context.labels.school },
      { label: context.itemStatus },
      { label: context.labels.classes, classes: "full-width" }
    ];
    context.properties.active = this.parent.labels?.components?.tags;
    context.parts = ["dnd5e.details-spell", "dnd5e.field-uses"];
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Attack classification of this spell.
   * @type {"spell"}
   */
  get attackClassification() {
    return "spell";
  }

  /* -------------------------------------------- */

  /** @override */
  get availableAbilities() {
    if ( this.ability ) return new Set([this.ability]);
    const spellcasting = this.parent?.actor?.spellcastingClasses[this.sourceClass]?.spellcasting.ability
      ?? this.parent?.actor?.system.attributes?.spellcasting;
    return new Set(spellcasting ? [spellcasting] : []);
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.parent.labels.level,
      this.parent.labels.components.vsm + (this.parent.labels.materials ? ` (${this.parent.labels.materials})` : ""),
      ...this.parent.labels.components.tags
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get _typeAbilityMod() {
    return this.availableAbilities.first() ?? "int";
  }

  /* -------------------------------------------- */

  /** @override */
  get criticalThreshold() {
    return this.parent?.actor?.flags.dnd5e?.spellCriticalThreshold ?? Infinity;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a linked activity that granted this spell using the stored `cachedFor` value.
   * @returns {Activity|null}
   */
  get linkedActivity() {
    const relative = this.parent.actor;
    if ( !relative ) return null;
    return fromUuidSync(this.parent.getFlag("dnd5e", "cachedFor"), { relative, strict: false }) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    return 1;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get scalingIncrease() {
    if ( this.level !== 0 ) return null;
    return Math.floor(((this.parent.actor?.system.cantripLevel?.(this.parent) ?? 0) + 1) / 6);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getRollData(...options) {
    const data = super.getRollData(...options);
    data.item.level = data.item.level + (this.parent.getFlag("dnd5e", "scaling") ?? 0);
    return data;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    if ( !this.parent.isEmbedded ) return;

    // Set as prepared for NPCs, and not prepared for PCs
    if ( ["character", "npc"].includes(this.parent.actor.type)
      && !foundry.utils.hasProperty(data, "system.preparation.prepared") ) {
      this.updateSource({ "preparation.prepared": this.parent.actor.type === "npc" });
    }

    if ( ["atwill", "innate"].includes(this.preparation.mode) || this.sourceClass ) return;
    const classes = new Set(Object.keys(this.parent.actor.spellcastingClasses));
    if ( !classes.size ) return;

    // Set the source class, and ensure the preparation mode matches if adding a prepared spell to an alt class
    const setClass = cls => {
      const update = { sourceClass: cls };
      const type = this.parent.actor.classes[cls].spellcasting.type;
      if ( (type !== "leveled") && (this.preparation.mode === "prepared") && (this.level > 0)
        && (type in CONFIG.DND5E.spellPreparationModes) ) update["preparation.mode"] = type;
      this.updateSource(update);
    };

    // If preparation mode matches an alt spellcasting type and matching class exists, set as that class
    if ( this.preparation.mode in CONFIG.DND5E.spellcastingTypes ) {
      const altClasses = classes.filter(i => this.parent.actor.classes[i].spellcasting.type === this.preparation.mode);
      if ( altClasses.size === 1 ) setClass(altClasses.first());
      return;
    }

    // If only a single spellcasting class is present, use that
    if ( classes.size === 1 ) {
      setClass(classes.first());
      return;
    }

    // Create intersection of spellcasting classes and classes that offer the spell
    const spellClasses = new Set(
      dnd5e.registry.spellLists.forSpell(this.parent._stats.compendiumSource).map(l => l.metadata.identifier)
    );
    const intersection = classes.intersection(spellClasses);
    if ( intersection.size === 1 ) setClass(intersection.first());
  }

  /* -------------------------------------------- */
  /*  Shims                                       */
  /* -------------------------------------------- */

  /**
   * Add additional data shims for spells.
   */
  _applySpellShims() {
    Object.defineProperty(this.activation, "cost", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `activation.cost` property on `SpellData` has been renamed `activation.value`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return this.value;
      },
      configurable: true,
      enumerable: false
    });
    Object.defineProperty(this, "scaling", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `scaling` property on `SpellData` has been deprecated and is now handled by individual damage parts.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return { mode: "none", formula: null };
      },
      configurable: true,
      enumerable: false
    });
    Object.defineProperty(this.target, "value", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `target.value` property on `SpellData` has been split into `target.template.size` and `target.affects.count`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return this.template.size || this.affects.count;
      },
      configurable: true,
      enumerable: false
    });
    Object.defineProperty(this.target, "width", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `target.width` property on `SpellData` has been moved to `target.template.width`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return this.template.width;
      },
      configurable: true,
      enumerable: false
    });
    Object.defineProperty(this.target, "units", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `target.units` property on `SpellData` has been moved to `target.template.units`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return this.template.units;
      },
      configurable: true,
      enumerable: false
    });
    Object.defineProperty(this.target, "type", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `target.type` property on `SpellData` has been split into `target.template.type` and `target.affects.type`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return this.template.type || this.affects.type;
      },
      configurable: true,
      enumerable: false
    });
    const firstActivity = this.activities.contents[0] ?? {};
    Object.defineProperty(this.target, "prompt", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `target.prompt` property on `SpellData` has moved into its activity.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return firstActivity.target?.prompt;
      },
      configurable: true,
      enumerable: false
    });
  }
}

/**
 * Mixin used to add system flags enforcement to types.
 * @template {foundry.abstract.Document} T
 * @param {typeof T} Base  The base document class to wrap.
 * @returns {typeof SystemFlags}
 * @mixin
 */
function SystemFlagsMixin(Base) {
  class SystemFlags extends Base {
    /**
     * Get the data model that represents system flags.
     * @type {typeof DataModel|null}
     * @abstract
     */
    get _systemFlagsDataModel() {
      return null;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    prepareData() {
      super.prepareData();
      if ( ("dnd5e" in this.flags) && this._systemFlagsDataModel ) {
        this.flags.dnd5e = new this._systemFlagsDataModel(this._source.flags.dnd5e, { parent: this });
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async setFlag(scope, key, value) {
      if ( (scope === "dnd5e") && this._systemFlagsDataModel ) {
        let diff;
        const changes = foundry.utils.expandObject({ [key]: value });
        if ( this.flags.dnd5e ) diff = this.flags.dnd5e.updateSource(changes, { dryRun: true });
        else diff = new this._systemFlagsDataModel(changes, { parent: this }).toObject();
        return this.update({ flags: { dnd5e: diff } });
      }
      return super.setFlag(scope, key, value);
    }
  }
  return SystemFlags;
}

/**
 * Mixin used to share some logic between Actor & Item documents.
 * @template {foundry.abstract.Document} T
 * @param {typeof T} Base  The base document class to wrap.
 * @returns {typeof SystemDocument}
 * @mixin
 */
function SystemDocumentMixin(Base) {
  class SystemDocument extends SystemFlagsMixin(Base) {
    /** @inheritDoc */
    get _systemFlagsDataModel() {
      return this.system?.metadata?.systemFlagsModel ?? null;
    }
  }
  return SystemDocument;
}

/**
 * Override and extend the basic Item implementation.
 */
class Item5e extends SystemDocumentMixin(Item) {

  /**
   * Caches an item linked to this one, such as a subclass associated with a class.
   * @type {Item5e}
   * @private
   */
  _classLink;

  /* -------------------------------------------- */

  /**
   * An object that tracks which tracks the changes to the data model which were applied by active effects
   * @type {object}
   */
  overrides = this.overrides ?? {};

  /* -------------------------------------------- */

  /**
   * Types that can be selected within the compendium browser.
   * @param {object} [options={}]
   * @param {Set<string>} [options.chosen]  Types that have been selected.
   * @returns {SelectChoices}
   */
  static compendiumBrowserTypes({ chosen=new Set() }={}) {
    const [generalTypes, physicalTypes] = Item.TYPES.reduce(([g, p], t) => {
      if ( ![CONST.BASE_DOCUMENT_TYPE, "backpack"].includes(t) ) {
        if ( CONFIG.Item.dataModels[t]?.metadata?.inventoryItem ) p.push(t);
        else g.push(t);
      }
      return [g, p];
    }, [[], []]);

    const makeChoices = (types, categoryChosen) => types.reduce((obj, type) => {
      obj[type] = {
        label: CONFIG.Item.typeLabels[type],
        chosen: chosen.has(type) || categoryChosen
      };
      return obj;
    }, {});
    const choices = makeChoices(generalTypes);
    choices.physical = {
      label: game.i18n.localize("DND5E.Item.Category.Physical"),
      children: makeChoices(physicalTypes, chosen.has("physical"))
    };
    return new SelectChoices(choices);
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options={}) {
    if ( data instanceof foundry.abstract.DataModel ) data = data.toObject();

    // Migrate backpack -> container.
    if ( data.type === "backpack" ) {
      data.type = "container";
      foundry.utils.setProperty(data, "flags.dnd5e.persistSourceMigration", true);
    }

    /**
     * A hook event that fires before source data is initialized for an Item in a compendium.
     * @function dnd5e.initializeItemSource
     * @memberof hookEvents
     * @param {Item5e} item     Item for which the data is being initialized.
     * @param {object} data     Source data being initialized.
     * @param {object} options  Additional data initialization options.
     */
    if ( options.pack || options.parent?.pack ) Hooks.callAll("dnd5e.initializeItemSource", this, data, options);

    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */
  /*  Item Properties                             */
  /* -------------------------------------------- */

  /**
   * Which ability score modifier is used by this item?
   * @type {string|null}
   * @see {@link ActionTemplate#abilityMod}
   */
  get abilityMod() {
    return this.system.abilityMod ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Should deletion of this item be allowed? Doesn't prevent programatic deletion, but affects UI controls.
   * @type {boolean}
   */
  get canDelete() {
    return !this.flags.dnd5e?.cachedFor;
  }

  /* -------------------------------------------- */

  /**
   * Should duplication of this item be allowed? Doesn't prevent programatic duplication, but affects UI controls.
   * @type {boolean}
   */
  get canDuplicate() {
    return !this.system.metadata?.singleton && !["class", "subclass"].includes(this.type)
      && !this.flags.dnd5e?.cachedFor;
  }

  /* --------------------------------------------- */

  /**
   * The item that contains this item, if it is in a container. Returns a promise if the item is located
   * in a compendium pack.
   * @type {Item5e|Promise<Item5e>|void}
   */
  get container() {
    if ( !this.system.container ) return;
    if ( this.isEmbedded ) return this.actor.items.get(this.system.container);
    if ( this.pack ) return game.packs.get(this.pack).getDocument(this.system.container);
    return game.items.get(this.system.container);
  }

  /* -------------------------------------------- */

  /**
   * What is the critical hit threshold for this item, if applicable?
   * @type {number|null}
   * @see {@link ActionTemplate#criticalThreshold}
   */
  get criticalThreshold() {
    return this.system.criticalThreshold ?? null;
  }

  /* --------------------------------------------- */

  /**
   * Does the Item implement an ability check as part of its usage?
   * @type {boolean}
   * @see {@link ActionTemplate#hasAbilityCheck}
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  get hasAbilityCheck() {
    return this.system.hasAbilityCheck ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does this item support advancement and have advancements defined?
   * @type {boolean}
   */
  get hasAdvancement() {
    return !!this.system.advancement?.length;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item have an area of effect target?
   * @type {boolean}
   * @see {@link ActivatedEffectTemplate#hasAreaTarget}
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  get hasAreaTarget() {
    return this.system.hasAreaTarget ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement an attack roll as part of its usage?
   * @type {boolean}
   * @see {@link ActionTemplate#hasAttack}
   */
  get hasAttack() {
    return this.system.hasAttack ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a damage roll as part of its usage?
   * @type {boolean}
   * @see {@link ActionTemplate#hasDamage}
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  get hasDamage() {
    return this.system.hasDamage ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item target one or more distinct targets?
   * @type {boolean}
   * @see {@link ActivatedEffectTemplate#hasIndividualTarget}
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  get hasIndividualTarget() {
    return this.system.hasIndividualTarget ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Is this Item limited in its ability to be used by charges or by recharge?
   * @type {boolean}
   * @see {@link ActivatedEffectTemplate#hasLimitedUses}
   * @see {@link FeatData#hasLimitedUses}
   */
  get hasLimitedUses() {
    return this.system.hasLimitedUses ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does this Item draw from a resource?
   * @type {boolean}
   * @see {@link ActivatedEffectTemplate#hasResource}
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  get hasResource() {
    return this.system.hasResource ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does this Item draw from ammunition?
   * @type {boolean}
   * @see {@link ActivatedEffectTemplate#hasAmmo}
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  get hasAmmo() {
    return this.system.hasAmmo ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a saving throw as part of its usage?
   * @type {boolean}
   * @see {@link ActionTemplate#hasSave}
   */
  get hasSave() {
    return this.system.hasSave ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item have a target?
   * @type {boolean}
   * @see {@link ActivatedEffectTemplate#hasTarget}
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  get hasTarget() {
    return this.system.hasTarget ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Return an item's identifier.
   * @type {string}
   */
  get identifier() {
    if ( this.system.identifier ) return this.system.identifier;
    const identifier = this.name.replaceAll(/(\w+)([\\|/])(\w+)/g, "$1-$3");
    return identifier.slugify({ strict: true });
  }

  /* --------------------------------------------- */

  /**
   * Is this Item an activatable item?
   * @type {boolean}
   */
  get isActive() {
    return this.system.isActive ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Is this item any of the armor subtypes?
   * @type {boolean}
   * @see {@link EquipmentTemplate#isArmor}
   */
  get isArmor() {
    return this.system.isArmor ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Does the item provide an amount of healing instead of conventional damage?
   * @type {boolean}
   * @see {@link ActionTemplate#isHealing}
   */
  get isHealing() {
    return this.system.isHealing ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Is this item a separate large object like a siege engine or vehicle component that is
   * usually mounted on fixtures rather than equipped, and has its own AC and HP?
   * @type {boolean}
   * @see {@link EquipmentData#isMountable}
   * @see {@link WeaponData#isMountable}
   */
  get isMountable() {
    return this.system.isMountable ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Is this class item the original class for the containing actor? If the item is not a class or it is not
   * embedded in an actor then this will return `null`.
   * @type {boolean|null}
   */
  get isOriginalClass() {
    if ( this.type !== "class" || !this.isEmbedded || !this.parent.system.details?.originalClass ) return null;
    return this.id === this.parent.system.details.originalClass;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a versatile damage roll as part of its usage?
   * @type {boolean}
   * @see {@link ActionTemplate#isVersatile}
   */
  get isVersatile() {
    return this.system.isVersatile ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Is the item rechargeable?
   * @type {boolean}
   */
  get hasRecharge() {
    return this.hasLimitedUses && (this.system.uses?.recovery[0]?.period === "recharge");
  }

  /* --------------------------------------------- */

  /**
   * Is the item on recharge cooldown?
   * @type {boolean}
   */
  get isOnCooldown() {
    return this.hasRecharge && (this.system.uses.value < 1);
  }

  /* --------------------------------------------- */

  /**
   * Does this item require concentration?
   * @type {boolean}
   */
  get requiresConcentration() {
    if ( this.system.validProperties.has("concentration") && this.system.properties.has("concentration") ) return true;
    return this.system.activities?.contents[0]?.duration.concentration ?? false;
  }

  /* -------------------------------------------- */

  /**
   * Class associated with this subclass. Always returns null on non-subclass or non-embedded items.
   * @type {Item5e|null}
   */
  get class() {
    if ( !this.isEmbedded || (this.type !== "subclass") ) return null;
    const cid = this.system.classIdentifier;
    return this._classLink ??= this.parent.items.find(i => (i.type === "class") && (i.identifier === cid));
  }

  /* -------------------------------------------- */

  /**
   * Subclass associated with this class. Always returns null on non-class or non-embedded items.
   * @type {Item5e|null}
   */
  get subclass() {
    if ( !this.isEmbedded || (this.type !== "class") ) return null;
    const items = this.parent.items;
    const cid = this.identifier;
    return this._classLink ??= items.find(i => (i.type === "subclass") && (i.system.classIdentifier === cid));
  }

  /* -------------------------------------------- */

  /**
   * Retrieve scale values for current level from advancement data.
   * @type {object}
   */
  get scaleValues() {
    if ( !this.advancement.byType.ScaleValue ) return {};
    const level = this.type === "class" ? this.system.levels : this.type === "subclass" ? this.class?.system.levels
      : this.parent?.system.details.level ?? 0;
    return this.advancement.byType.ScaleValue.reduce((obj, advancement) => {
      obj[advancement.identifier] = advancement.valueForLevel(level);
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Scaling increase for this item based on flag or item-type specific details.
   * @type {number}
   */
  get scalingIncrease() {
    return this.system?.scalingIncrease ?? this.getFlag("dnd5e", "scaling") ?? 0;
  }

  /* -------------------------------------------- */

  /**
   * Does this item scale with any kind of consumption?
   * @type {string|null}
   */
  get usageScaling() {
    // TODO: Re-implement on activity
    const { level, preparation, consume } = this.system;
    const isLeveled = (this.type === "spell") && (level > 0);
    if ( isLeveled && CONFIG.DND5E.spellPreparationModes[preparation.mode]?.upcast ) return "slot";
    else if ( isLeveled && this.hasResource && consume.scale ) return "resource";
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Spellcasting details for a class or subclass.
   *
   * @typedef {object} SpellcastingDescription
   * @property {string} type              Spellcasting type as defined in ``CONFIG.DND5E.spellcastingTypes`.
   * @property {string|null} progression  Progression within the specified spellcasting type if supported.
   * @property {string} ability           Ability used when casting spells from this class or subclass.
   * @property {number|null} levels       Number of levels of this class or subclass's class if embedded.
   */

  /**
   * Retrieve the spellcasting for a class or subclass. For classes, this will return the spellcasting
   * of the subclass if it overrides the class. For subclasses, this will return the class's spellcasting
   * if no spellcasting is defined on the subclass.
   * @type {SpellcastingDescription|null}  Spellcasting object containing progression & ability.
   */
  get spellcasting() {
    const spellcasting = this.system.spellcasting;
    if ( !spellcasting ) return null;
    const isSubclass = this.type === "subclass";
    const classSC = isSubclass ? this.class?.system.spellcasting : spellcasting;
    const subclassSC = isSubclass ? spellcasting : this.subclass?.system.spellcasting;
    const finalSC = foundry.utils.deepClone(
      ( subclassSC && (subclassSC.progression !== "none") ) ? subclassSC : classSC
    );
    return finalSC ?? null;
  }

  /* -------------------------------------------- */
  /*  Active Effects                              */
  /* -------------------------------------------- */

  /**
   * Get all ActiveEffects that may apply to this Item.
   * @yields {ActiveEffect5e}
   * @returns {Generator<ActiveEffect5e, void, void>}
   */
  *allApplicableEffects() {
    for ( const effect of this.effects ) {
      if ( effect.isAppliedEnchantment ) yield effect;
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply any transformation to the Item data which are caused by enchantment Effects.
   */
  applyActiveEffects() {
    const overrides = {};

    // Organize non-disabled effects by their application priority
    const changes = [];
    for ( const effect of this.allApplicableEffects() ) {
      if ( !effect.active ) continue;
      changes.push(...effect.changes.map(change => {
        const c = foundry.utils.deepClone(change);
        c.effect = effect;
        c.priority ??= c.mode * 10;
        return c;
      }));
    }
    changes.sort((a, b) => a.priority - b.priority);

    // Apply all changes
    for ( const change of changes ) {
      if ( !change.key ) continue;
      const changes = change.effect.apply(this, change);
      Object.assign(overrides, changes);
    }

    // Expand the set of final overrides
    this.overrides = foundry.utils.expandObject(overrides);
  }

  /* -------------------------------------------- */

  /**
   * Should this item's active effects be suppressed.
   * @type {boolean}
   */
  get areEffectsSuppressed() {
    const requireEquipped = (this.type !== "consumable")
      || ["rod", "trinket", "wand"].includes(this.system.type.value);
    if ( requireEquipped && (this.system.equipped === false) ) return true;
    return !this.system.attuned && (this.system.attunement === "required");
  }

  /* -------------------------------------------- */
  /*  Data Initialization                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  clone(data={}, options={}) {
    if ( options.save ) return super.clone(data, options);
    if ( this.parent ) this.parent._embeddedPreparation = true;
    const item = super.clone(data, options);
    if ( item.parent ) {
      delete item.parent._embeddedPreparation;
      item.prepareFinalAttributes();
    }
    return item;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.labels = {};
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    super.prepareEmbeddedDocuments();
    for ( const activity of this.system.activities ?? [] ) activity.prepareData();
    if ( !this.actor || this.actor._embeddedPreparation ) this.applyActiveEffects();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    this.labels ??= {};
    super.prepareDerivedData();

    // Clear out linked item cache
    this._classLink = undefined;

    // Advancement
    this._prepareAdvancement();

    // Item Properties
    if ( this.system.properties ) {
      this.labels.properties = this.system.properties.reduce((acc, prop) => {
        if ( (prop === "concentration") && !this.requiresConcentration ) return acc;
        acc.push({
          abbr: prop,
          label: CONFIG.DND5E.itemProperties[prop]?.label,
          icon: CONFIG.DND5E.itemProperties[prop]?.icon
        });
        return acc;
      }, []);
    }

    // Un-owned items can have their final preparation done here, otherwise this needs to happen in the owning Actor
    if ( !this.isOwned ) this.prepareFinalAttributes();
  }

  /* -------------------------------------------- */

  /**
   * Prepare advancement objects from stored advancement data.
   * @protected
   */
  _prepareAdvancement() {
    const minAdvancementLevel = ["class", "subclass"].includes(this.type) ? 1 : 0;
    this.advancement = {
      byId: {},
      byLevel: Object.fromEntries(
        Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(minAdvancementLevel).map(l => [l, []])
      ),
      byType: {},
      needingConfiguration: []
    };
    for ( const advancement of this.system.advancement ?? [] ) {
      if ( !(advancement instanceof Advancement) ) continue;
      this.advancement.byId[advancement.id] = advancement;
      this.advancement.byType[advancement.type] ??= [];
      this.advancement.byType[advancement.type].push(advancement);
      advancement.levels.forEach(l => this.advancement.byLevel[l]?.push(advancement));
      if ( !advancement.levels.length
        || ((advancement.levels.length === 1) && (advancement.levels[0] < minAdvancementLevel)) ) {
        this.advancement.needingConfiguration.push(advancement);
      }
    }
    Object.entries(this.advancement.byLevel).forEach(([lvl, data]) => data.sort((a, b) => {
      return a.sortingValueForLevel(lvl).localeCompare(b.sortingValueForLevel(lvl), game.i18n.lang);
    }));
  }

  /* -------------------------------------------- */

  /**
   * Determine an item's proficiency level based on its parent actor's proficiencies.
   * @protected
   */
  _prepareProficiency() {
    if ( !["spell", "weapon", "equipment", "tool", "feat", "consumable"].includes(this.type) ) return;
    if ( !this.actor?.system.attributes?.prof ) {
      this.system.prof = new Proficiency(0, 0);
      return;
    }

    this.system.prof = new Proficiency(this.actor.system.attributes.prof, this.system.proficiencyMultiplier ?? 0);
  }

  /* -------------------------------------------- */

  /**
   * Compute item attributes which might depend on prepared actor data. If this item is embedded this method will
   * be called after the actor's data is prepared.
   * Otherwise, it will be called at the end of `Item5e#prepareDerivedData`.
   */
  prepareFinalAttributes() {
    this._prepareProficiency();
    this.system.prepareFinalData?.();
    this._prepareLabels();
  }

  /* -------------------------------------------- */

  /**
   * Prepare top-level summary labels based on configured activities.
   * @protected
   */
  _prepareLabels() {
    const activations = this.labels.activations = [];
    const attacks = this.labels.attacks = [];
    const damages = this.labels.damages = [];
    if ( !this.system.activities?.size ) return;
    for ( const activity of this.system.activities ) {
      if ( !("activation" in activity) ) continue;
      const activationLabels = activity.activationLabels;
      if ( activationLabels ) activations.push(
        { ...activationLabels, concentrationDuration: activity.labels.concentrationDuration }
      );
      if ( activity.type === "attack" ) {
        const { toHit, modifier } = activity.labels;
        attacks.push({ toHit, modifier });
      }
      if ( activity.labels?.damage?.length ) damages.push(...activity.labels.damage);
    }
    if ( activations.length ) {
      Object.assign(this.labels, activations[0]);
      delete activations[0].concentrationDuration;
    }
    if ( attacks.length ) Object.assign(this.labels, attacks[0]);
  }

  /* -------------------------------------------- */

  /**
   * Update a label to the Item detailing its total to hit bonus from the following sources:
   * - item's actor's proficiency bonus if applicable
   * - item's actor's global bonuses to the given item type
   * - item document's innate & magical attack bonuses
   * - item's ammunition if applicable
   * @returns {{rollData: object, parts: string[]}|null}  Data used in the item's Attack roll.
   */
  getAttackToHit() {
    foundry.utils.logCompatibilityWarning(
      "The `getAttackToHit` method on `Item5e` has moved to `getAttackData` on `AttackActivity`.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
    );

    const activity = this.system.activities?.getByType("attack")[0];
    if ( !activity ) return null;
    const { data: rollData, parts } = activity.getAttackData();
    return { rollData, parts };
  }

  /* -------------------------------------------- */

  /**
   * Render a rich tooltip for this item.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {Promise<{content: string, classes: string[]}>|null}
   */
  richTooltip(enrichmentOptions={}) {
    return this.system.richTooltip?.() ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Configuration data for an item usage being prepared.
   *
   * @typedef {object} ItemUseConfiguration
   * @property {boolean} createMeasuredTemplate     Should this item create a template?
   * @property {boolean} createSummons              Should this item create a summoned creature?
   * @property {boolean} consumeResource            Should this item consume a (non-ammo) resource?
   * @property {boolean} consumeSpellSlot           Should this item (a spell) consume a spell slot?
   * @property {boolean} consumeUsage               Should this item consume its limited uses or recharge?
   * @property {string} enchantmentProfile          ID of the enchantment to apply.
   * @property {boolean} promptEnchantment          Does an enchantment profile need to be selected?
   * @property {string|number|null} slotLevel       The spell slot type or level to consume by default.
   * @property {string|null} summonsProfile         ID of the summoning profile to use.
   * @property {number|null} resourceAmount         The amount to consume by default when scaling with consumption.
   * @property {boolean} beginConcentrating         Should this item initiate concentration?
   * @property {string|null} endConcentration       The id of the active effect to end concentration on, if any.
   */

  /**
   * Additional options used for configuring item usage.
   *
   * @typedef {object} ItemUseOptions
   * @property {boolean} configureDialog  Display a configuration dialog for the item usage, if applicable?
   * @property {string} rollMode          The roll display mode with which to display (or not) the card.
   * @property {boolean} createMessage    Whether to automatically create a chat message (if true) or simply return
   *                                      the prepared chat message data (if false).
   * @property {object} flags             Additional flags added to the chat message.
   * @property {Event} event              The browser event which triggered the item usage, if any.
   */

  /**
   * Trigger an Item usage, optionally creating a chat message with followup actions.
   * @param {ActivityUseConfiguration} config       Configuration info for the activation.
   * @param {boolean} [config.legacy=true]          Whether this is a legacy invocation, using the old signature.
   * @param {boolean} [config.chooseActivity=false] Force the activity selection prompt unless the fast-forward modifier
   *                                                is held.
   * @param {ActivityDialogConfiguration} dialog    Configuration info for the usage dialog.
   * @param {ActivityMessageConfiguration} message  Configuration info for the created chat message.
   * @returns {Promise<ActivityUsageResults|ChatMessage|object|void>}  Returns the usage results for the triggered
   *                                                                   activity, or the chat message if the Item had no
   *                                                                   activities and was posted directly to chat.
   */
  async use(config={}, dialog={}, message={}) {
    if ( this.pack ) return;

    let event = config.event;
    if ( config.legacy !== false ) {
      foundry.utils.logCompatibilityWarning(
        "The `Item5e#use` method has a different signature. Pass the `legacy: false` option to suppress this warning "
        + " once the appropriate updates have been made.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      event = dialog?.event;
    }
    const activities = this.system.activities?.filter(a =>
      !this.getFlag("dnd5e", "riders.activity")?.includes(a.id) && a.canUse
    );
    if ( activities?.length ) {
      const { legacy, chooseActivity, ...activityConfig } = config;
      let usageConfig = activityConfig;
      let dialogConfig = dialog;
      let messageConfig = message;
      let activity = activities[0];
      if ( ((activities.length > 1) || chooseActivity) && !event?.shiftKey ) {
        activity = await ActivityChoiceDialog.create(this);
      }
      if ( !activity ) return;
      if ( legacy !== false ) {
        usageConfig = {};
        dialogConfig = {};
        messageConfig = {};
        activity._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, activityConfig, dialog);
      }
      return activity.use(usageConfig, dialogConfig, messageConfig);
    }
    if ( this.actor ) return this.displayCard(message);
  }

  /* -------------------------------------------- */

  /**
   * Handle item's consumption.
   * @param {Item5e} item  Item or clone to use when calculating updates.
   * @param {ItemUseConfiguration} config  Configuration data for the item usage being prepared.
   * @param {ItemUseOptions} options       Additional options used for configuring item usage.
   * @returns {false|void}                 Returns `false` if any further usage should be canceled.
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  async consume(item, config, options) {
    foundry.utils.logCompatibilityWarning(
      "The `Item5e#consume` method has been deprecated and should now be called directly on the activity.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );
    if ( this.system.activities ) {
      const activity = this.system.activities.contents[0];
      if ( activity ) {
        const usageConfig = {};
        const dialogConfig = {};
        const messageConfig = {};
        activity._applyDeprecatedConfigs(usageConfig, dialogConfig, messageConfig, config, options);
        return activity.consume(usageConfig, messageConfig);
      }
    }
    return false;
  }

  /* -------------------------------------------- */

  /**
   * Display the chat card for an Item as a Chat Message
   * @param {Partial<ActivityMessageConfiguration>} [message]  Configuration info for the created chat message.
   * @returns {Promise<ChatMessage5e|object|void>}
   */
  async displayCard(message={}) {
    const context = {
      actor: this.actor,
      config: CONFIG.DND5E,
      tokenId: this.actor.token?.uuid || null,
      item: this,
      data: await this.system.getCardData(),
      isSpell: this.type === "spell"
    };

    const messageConfig = foundry.utils.mergeObject({
      create: message?.createMessage ?? true,
      data: {
        content: await renderTemplate("systems/dnd5e/templates/chat/item-card.hbs", context),
        flags: {
          "core.canPopout": true,
          "dnd5e.item": { id: this.id, uuid: this.uuid, type: this.type }
        },
        speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.actor.token })
      },
      rollMode: game.settings.get("core", "rollMode")
    }, message);

    // Merge in the flags from options
    if ( foundry.utils.getType(message.flags) === "Object" ) {
      foundry.utils.mergeObject(messageConfig.data.flags, message.flags);
      delete messageConfig.flags;
    }

    /**
     * A hook event that fires before an item chat card is created without using an activity.
     * @function dnd5e.preDisplayCardV2
     * @memberof hookEvents
     * @param {Item5e} item                           Item for which the card will be created.
     * @param {ActivityMessageConfiguration} message  Configuration for the roll message.
     * @returns {boolean}                             Return `false` to prevent the card from being displayed.
     */
    if ( Hooks.call("dnd5e.preDisplayCardV2", this, messageConfig) === false ) return;

    if ( "dnd5e.preDisplayCard" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preDisplayCard` hook has been deprecated and replaced with `dnd5e.preDisplayCardV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      const hookData = { createMessage: messageConfig.create };
      Hooks.callAll("dnd5e.preDisplayCard", this, messageConfig.data, hookData);
      messageConfig.create = hookData.createMessage;
    }

    ChatMessage.applyRollMode(messageConfig.data, messageConfig.rollMode);
    const card = messageConfig.create === false ? messageConfig.data : await ChatMessage.create(messageConfig.data);

    /**
     * A hook event that fires after an item chat card is created.
     * @function dnd5e.displayCard
     * @memberof hookEvents
     * @param {Item5e} item                Item for which the chat card is being displayed.
     * @param {ChatMessage5e|object} card  The created ChatMessage instance or ChatMessageData depending on whether
     *                                     options.createMessage was set to `true`.
     */
    Hooks.callAll("dnd5e.displayCard", this, card);

    return card;
  }

  /* -------------------------------------------- */
  /*  Chat Cards                                  */
  /* -------------------------------------------- */

  /**
   * Prepare an object of chat data used to display a card for the Item in the chat log.
   * @param {object} htmlOptions    Options used by the TextEditor.enrichHTML function.
   * @returns {object}              An object of chat data to render.
   */
  async getChatData(htmlOptions={}) {
    const context = {};
    let { identified, unidentified, description } = this.system;

    // Rich text description
    const isIdentified = identified !== false;
    description = game.user.isGM || isIdentified ? description.value : unidentified?.description;
    context.description = await TextEditor.enrichHTML(description ?? "", {
      relativeTo: this,
      rollData: this.getRollData(),
      ...htmlOptions
    });

    // Type specific properties
    context.properties = [
      ...this.system.chatProperties ?? [],
      ...this.system.equippableItemCardProperties ?? [],
      ...Object.values(this.labels.activations?.[0] ?? {})
    ].filter(p => p);

    return context;
  }

  /* -------------------------------------------- */
  /*  Item Rolls - Attack, Damage, Saves, Checks  */
  /* -------------------------------------------- */

  /**
   * Place an attack roll using an item (weapon, feat, spell, or equipment)
   * Rely upon the d20Roll logic for the core implementation
   *
   * @param {D20RollConfiguration} options  Roll options which are configured and provided to the d20Roll function
   * @returns {Promise<D20Roll|null>}       A Promise which resolves to the created Roll instance
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  async rollAttack({ spellLevel, ...options }={}) {
    foundry.utils.logCompatibilityWarning(
      "The `Item5e#rollAttack` method has been deprecated and should now be called directly on the attack activity.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );

    let item = this;
    if ( spellLevel && (this.type === "spell") ) {
      item = item.clone({ "flags.dnd5e.scaling": Math.max(0, spellLevel - item.system.level) }, { keepId: true });
    }

    const activity = item.system.activities?.getByType("attack")[0];
    if ( !activity ) throw new Error("This Item does not have an Attack activity to roll!");

    const rollConfig = {};
    const dialogConfig = {};
    const messageConfig = {};
    _applyDeprecatedD20Configs(rollConfig, dialogConfig, messageConfig, options);

    const rolls = await activity.rollAttack(rollConfig, dialogConfig, messageConfig);
    return rolls?.[0] ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Place a damage roll using an item (weapon, feat, spell, or equipment)
   * Rely upon the damageRoll logic for the core implementation.
   * @param {object} [config]
   * @param {MouseEvent} [config.event]    An event which triggered this roll, if any
   * @param {boolean} [config.critical]    Should damage be rolled as a critical hit?
   * @param {number} [config.spellLevel]   If the item is a spell, override the level for damage scaling
   * @param {boolean} [config.versatile]   If the item is a weapon, roll damage using the versatile formula
   * @param {DamageRollConfiguration} [config.options]  Additional options passed to the damageRoll function
   * @returns {Promise<DamageRoll[]>}      A Promise which resolves to the created Roll instances, or null if the action
   *                                       cannot be performed.
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  async rollDamage({ spellLevel, ...options }={}) {
    foundry.utils.logCompatibilityWarning(
      "The `Item5e#rollDamage` method has been deprecated and should now be called directly on an activity.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );

    let item = this;
    if ( spellLevel && (this.type === "spell") ) {
      item = item.clone({ "flags.dnd5e.scaling": Math.max(0, spellLevel - item.system.level) }, { keepId: true });
    }

    const activity = item.system.activities?.getByType("attack")[0] || item.system.activities?.getByType("damage")[0]
      || item.system.activities?.getByType("save")[0] || item.system.activities?.getByType("heal")[0];
    if ( !activity ) throw new Error("This Item does not have a damaging activity to roll!");

    const returnMultiple = options.returnMultiple;
    const rolls = await activity.rollDamage(options);
    return returnMultiple ? rolls : rolls?.[0];
  }

  /* -------------------------------------------- */

  /**
   * Prepare data needed to roll an attack using an item (weapon, feat, spell, or equipment)
   * and then pass it off to `d20Roll`.
   * @param {object} [options]
   * @param {boolean} [options.spellLevel]  Level at which a spell is cast.
   * @returns {Promise<Roll>}   A Promise which resolves to the created Roll instance.
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  async rollFormula({spellLevel}={}) {
    foundry.utils.logCompatibilityWarning(
      "The `Item5e#rollFormula` method has been deprecated and should now be called directly on the utility activity.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );

    let item = this;
    if ( spellLevel && (this.type === "spell") ) {
      item = item.clone({ "flags.dnd5e.scaling": Math.max(0, spellLevel - item.system.level) }, { keepId: true });
    }

    const activity = item.system.activities?.getByType("utility")[0];
    if ( !activity ) throw new Error("This Item does not have a Utility activity to roll!");

    const rolls = await activity.rollFormula({}, { configure: false });
    return rolls?.[0];
  }

  /* -------------------------------------------- */

  /**
   * Perform an ability recharge test for an item which uses the d6 recharge mechanic.
   * @returns {Promise<Roll|void>}   A Promise which resolves to the created Roll instance
   * @deprecated since DnD5e 4.0, targeted for removal in DnD5e 4.4
   */
  async rollRecharge() {
    foundry.utils.logCompatibilityWarning(
      "The `rollRecharge` method on `Item5e` has been moved to `system.uses.rollRecharge`.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );
    return (await this.system.uses?.rollRecharge())?.[0];
  }

  /* -------------------------------------------- */

  /**
   * Prepare data needed to roll a tool check and then pass it off to `d20Roll`.
   * @param {D20RollConfiguration} [options]  Roll configuration options provided to the d20Roll function.
   * @returns {Promise<Roll>}                 A Promise which resolves to the created Roll instance.
   */
  async rollToolCheck(options={}) {
    if ( this.type !== "tool" ) throw new Error("Wrong item type!");
    return this.actor?.rollToolCheck({
      ability: this.system.ability,
      bonus: this.system.bonus,
      prof: this.system.prof,
      item: this,
      tool: this.system.type.baseItem,
      ...options
    });
  }

  /* -------------------------------------------- */

  /**
   * @inheritdoc
   * @param {object} [options]
   * @param {boolean} [options.deterministic] Whether to force deterministic values for data properties that could be
   *                                          either a die term or a flat term.
   */
  getRollData({ deterministic=false }={}) {
    let data;
    if ( this.system.getRollData ) data = this.system.getRollData({ deterministic });
    else data = { ...(this.actor?.getRollData({ deterministic }) ?? {}), item: { ...this.system } };
    if ( data?.item ) {
      data.item.flags = { ...this.flags };
      data.item.name = this.name;
    }
    data.scaling = new Scaling(this.scalingIncrease);
    return data;
  }

  /* -------------------------------------------- */
  /*  Chat Message Helpers                        */
  /* -------------------------------------------- */

  /**
   * Apply listeners to chat messages.
   * @param {jQuery|HTMLElement} html  Rendered chat message.
   */
  static chatListeners(html) {
    html = html instanceof HTMLElement ? html : html[0];
    html.addEventListener("click", event => {
      if ( event.target.closest("[data-context-menu]") ) {
        event.preventDefault();
        event.stopPropagation();
        event.target.closest("[data-message-id]").dispatchEvent(new PointerEvent("contextmenu", {
          view: window, bubbles: true, cancelable: true
        }));
      } else if ( event.target.closest(".collapsible") ) {
        this._onChatCardToggleContent(event);
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the visibility of chat card content when the name is clicked
   * @param {Event} event   The originating click event
   * @private
   */
  static _onChatCardToggleContent(event) {
    const header = event.target.closest(".collapsible");
    if ( !event.target.closest(".collapsible-content.card-content") ) {
      event.preventDefault();
      header.classList.toggle("collapsed");

      // Clear the height from the chat popout container so that it appropriately resizes.
      const popout = header.closest(".chat-popout");
      if ( popout ) popout.style.height = "";
    }
  }

  /* -------------------------------------------- */
  /*  Activities & Advancements                   */
  /* -------------------------------------------- */

  /**
   * Create a new activity of the specified type.
   * @param {string} type                          Type of activity to create.
   * @param {object} [data]                        Data to use when creating the activity.
   * @param {object} [options={}]
   * @param {boolean} [options.renderSheet=true]  Should the sheet be rendered after creation?
   * @returns {Promise<ActivitySheet|null>}
   */
  async createActivity(type, data={}, { renderSheet=true }={}) {
    if ( !this.system.activities ) return;

    const config = CONFIG.DND5E.activityTypes[type];
    if ( !config ) throw new Error(`${type} not found in CONFIG.DND5E.activityTypes`);
    const cls = config.documentClass;

    const createData = foundry.utils.deepClone(data);
    const activity = new cls({ type, ...data }, { parent: this });
    if ( activity._preCreate(createData) === false ) return;

    await this.update({ [`system.activities.${activity.id}`]: activity.toObject() });
    const created = this.system.activities.get(activity.id);
    if ( renderSheet ) return created.sheet?.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Update an activity belonging to this item.
   * @param {string} id          ID of the activity to update.
   * @param {object} updates     Updates to apply to this activity.
   * @returns {Promise<Item5e>}  This item with the changes applied.
   */
  updateActivity(id, updates) {
    if ( !this.system.activities ) return this;
    if ( !this.system.activities.has(id) ) throw new Error(`Activity of ID ${id} could not be found to update`);
    return this.update({ [`system.activities.${id}`]: updates });
  }

  /* -------------------------------------------- */

  /**
   * Remove an activity from this item.
   * @param {string} id          ID of the activity to remove.
   * @returns {Promise<Item5e>}  This item with the changes applied.
   */
  async deleteActivity(id) {
    const activity = this.system.activities?.get(id);
    if ( !activity ) return this;
    await Promise.allSettled(activity.constructor._apps.get(activity.uuid)?.map(a => a.close()) ?? []);
    return this.update({ [`system.activities.-=${id}`]: null });
  }

  /* -------------------------------------------- */

  /**
   * Create a new advancement of the specified type.
   * @param {string} type                          Type of advancement to create.
   * @param {object} [data]                        Data to use when creating the advancement.
   * @param {object} [options]
   * @param {boolean} [options.showConfig=true]    Should the new advancement's configuration application be shown?
   * @param {boolean} [options.source=false]       Should a source-only update be performed?
   * @returns {Promise<AdvancementConfig>|Item5e}  Promise for advancement config for new advancement if local
   *                                               is `false`, or item with newly added advancement.
   */
  createAdvancement(type, data={}, { showConfig=true, source=false }={}) {
    if ( !this.system.advancement ) return this;

    const config = CONFIG.DND5E.advancementTypes[type];
    if ( !config ) throw new Error(`${type} not found in CONFIG.DND5E.advancementTypes`);
    const cls = config.documentClass;

    if ( !config.validItemTypes.has(this.type) || !cls.availableForItem(this) ) {
      throw new Error(`${type} advancement cannot be added to ${this.name}`);
    }

    const createData = foundry.utils.deepClone(data);
    const advancement = new cls(data, {parent: this});
    if ( advancement._preCreate(createData) === false ) return;

    const advancementCollection = this.toObject().system.advancement;
    advancementCollection.push(advancement.toObject());
    if ( source ) return this.updateSource({"system.advancement": advancementCollection});
    return this.update({ "system.advancement": advancementCollection }).then(() => {
      if ( showConfig ) return this.advancement.byId[advancement.id]?.sheet?.render(true);
      return this;
    });
  }

  /* -------------------------------------------- */

  /**
   * Update an advancement belonging to this item.
   * @param {string} id                       ID of the advancement to update.
   * @param {object} updates                  Updates to apply to this advancement.
   * @param {object} [options={}]
   * @param {boolean} [options.source=false]  Should a source-only update be performed?
   * @returns {Promise<Item5e>|Item5e}        This item with the changes applied, promised if source is `false`.
   */
  updateAdvancement(id, updates, { source=false }={}) {
    if ( !this.system.advancement ) return this;
    const idx = this.system.advancement.findIndex(a => a._id === id);
    if ( idx === -1 ) throw new Error(`Advancement of ID ${id} could not be found to update`);

    const advancement = this.advancement.byId[id];
    if ( source ) {
      advancement.updateSource(updates);
      advancement.render();
      return this;
    }

    const advancementCollection = this.toObject().system.advancement;
    const clone = new advancement.constructor(advancementCollection[idx], { parent: advancement.parent });
    clone.updateSource(updates);
    advancementCollection[idx] = clone.toObject();
    return this.update({"system.advancement": advancementCollection}).then(r => {
      advancement.render(false, { height: "auto" });
      return r;
    });
  }

  /* -------------------------------------------- */

  /**
   * Remove an advancement from this item.
   * @param {string} id                       ID of the advancement to remove.
   * @param {object} [options={}]
   * @param {boolean} [options.source=false]  Should a source-only update be performed?
   * @returns {Promise<Item5e>|Item5e}        This item with the changes applied.
   */
  deleteAdvancement(id, { source=false }={}) {
    if ( !this.system.advancement ) return this;

    const advancementCollection = this.toObject().system.advancement.filter(a => a._id !== id);
    if ( source ) return this.updateSource({"system.advancement": advancementCollection});
    return this.update({"system.advancement": advancementCollection});
  }

  /* -------------------------------------------- */

  /**
   * Duplicate an advancement, resetting its value to default and giving it a new ID.
   * @param {string} id                             ID of the advancement to duplicate.
   * @param {object} [options]
   * @param {boolean} [options.showConfig=true]     Should the new advancement's configuration application be shown?
   * @param {boolean} [options.source=false]        Should a source-only update be performed?
   * @returns {Promise<AdvancementConfig>|Item5e}   Promise for advancement config for duplicate advancement if source
   *                                                is `false`, or item with newly duplicated advancement.
   */
  duplicateAdvancement(id, options) {
    const original = this.advancement.byId[id];
    if ( !original ) return this;
    const duplicate = original.toObject();
    delete duplicate._id;
    if ( original.constructor.metadata.dataModels?.value ) {
      duplicate.value = (new original.constructor.metadata.dataModels.value()).toObject();
    } else {
      duplicate.value = original.constructor.metadata.defaults?.value ?? {};
    }
    return this.createAdvancement(original.constructor.typeName, duplicate, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getEmbeddedDocument(embeddedName, id, options) {
    let doc;
    switch ( embeddedName ) {
      case "Activity": doc = this.system.activities?.get(id); break;
      case "Advancement": doc = this.advancement.byId[id]; break;
      default: return super.getEmbeddedDocument(embeddedName, id, options);
    }
    if ( options?.strict && (advancement === undefined) ) {
      throw new Error(`The key ${id} does not exist in the ${embeddedName} Collection`);
    }
    return doc;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    // Create identifier based on name
    if ( this.system.hasOwnProperty("identifier") && !data.system?.identifier ) {
      this.updateSource({ "system.identifier": this.identifier });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    await this.system.onCreateActivities?.(data, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.system.preUpdateActivities?.(changed, options, user);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    await this.system.onUpdateActivities?.(changed, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDelete(options, userId) {
    super._onDelete(options, userId);
    await this.system.onDeleteActivities?.(options, userId);
    if ( userId !== game.user.id ) return;
    this.parent?.endConcentration?.(this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async deleteDialog(options={}) {
    // If item has advancement, handle it separately
    if ( this.actor?.system.metadata?.supportsAdvancement && !game.settings.get("dnd5e", "disableAdvancements") ) {
      const manager = AdvancementManager.forDeletedItem(this.actor, this.id);
      if ( manager.steps.length ) {
        try {
          const shouldRemoveAdvancements = await AdvancementConfirmationDialog.forDelete(this);
          if ( shouldRemoveAdvancements ) return manager.render(true);
          return this.delete({ shouldRemoveAdvancements });
        } catch(err) {
          return;
        }
      }
    }

    // Display custom delete dialog when deleting a container with contents
    const count = await this.system.contentsCount;
    if ( count ) {
      return Dialog.confirm({
        title: `${game.i18n.format("DOCUMENT.Delete", {type: game.i18n.localize("DND5E.Container")})}: ${this.name}`,
        content: `<h4>${game.i18n.localize("AreYouSure")}</h4>
          <p>${game.i18n.format("DND5E.ContainerDeleteMessage", {count})}</p>
          <label>
            <input type="checkbox" name="deleteContents">
            ${game.i18n.localize("DND5E.ContainerDeleteContents")}
          </label>`,
        yes: html => {
          const deleteContents = html.querySelector('[name="deleteContents"]').checked;
          this.delete({ deleteContents });
        },
        options: { ...options, jQuery: false }
      });
    }

    return super.deleteDialog(options);
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Add additional system-specific compendium context menu options for Item documents.
   * @param {jQuery} html            The compendium HTML.
   * @param {object{}} entryOptions  The default array of context menu options.
   */
  static addCompendiumContextOptions(html, entryOptions) {
    const makeUuid = li => {
      const pack = li[0].closest("[data-pack]")?.dataset.pack;
      return `Compendium.${pack}.Item.${li.data("documentId")}`;
    };
    entryOptions.push({
      name: "DND5E.Scroll.CreateScroll",
      icon: '<i class="fa-solid fa-scroll"></i>',
      callback: async li => {
        const spell = await fromUuid(makeUuid(li));
        const scroll = await Item5e.createScrollFromSpell(spell);
        if ( scroll ) Item5e.create(scroll);
      },
      condition: li => {
        const item = fromUuidSync(makeUuid(li));
        return (item?.type === "spell") && game.user.hasPermission("ITEM_CREATE");
      },
      group: "system"
    });
  }

  /* -------------------------------------------- */

  /**
   * Add additional system-specific sidebar directory context menu options for Item documents.
   * @param {jQuery} html            The sidebar HTML.
   * @param {object[]} entryOptions  The default array of context menu options.
   */
  static addDirectoryContextOptions(html, entryOptions) {
    entryOptions.push({
      name: "DND5E.Scroll.CreateScroll",
      icon: '<i class="fa-solid fa-scroll"></i>',
      callback: async li => {
        const spell = game.items.get(li.data("documentId"));
        const scroll = await Item5e.createScrollFromSpell(spell);
        if ( scroll ) Item5e.create(scroll);
      },
      condition: li => {
        const item = game.items.get(li.data("documentId"));
        return (item.type === "spell") && game.user.hasPermission("ITEM_CREATE");
      },
      group: "system"
    });
  }

  /* -------------------------------------------- */

  /**
   * @callback ItemContentsTransformer
   * @param {Item5e|object} item        Data for the item to transform.
   * @param {object} options
   * @param {string} options.container  ID of the container to create the items.
   * @param {number} options.depth      Current depth of the item being created.
   * @returns {Item5e|object|void}
   */

  /**
   * Prepare creation data for the provided items and any items contained within them. The data created by this method
   * can be passed to `createDocuments` with `keepId` always set to true to maintain links to container contents.
   * @param {Item5e[]} items                     Items to create.
   * @param {object} [context={}]                Context for the item's creation.
   * @param {Item5e} [context.container]         Container in which to create the item.
   * @param {boolean} [context.keepId=false]     Should IDs be maintained?
   * @param {ItemContentsTransformer} [context.transformAll]    Method called on provided items and their contents.
   * @param {ItemContentsTransformer} [context.transformFirst]  Method called only on provided items.
   * @returns {Promise<object[]>}                Data for items to be created.
   */
  static async createWithContents(items, { container, keepId=false, transformAll, transformFirst }={}) {
    let depth = 0;
    if ( container ) {
      depth = 1 + (await container.system.allContainers()).length;
      if ( depth > PhysicalItemTemplate.MAX_DEPTH ) {
        ui.notifications.warn(game.i18n.format("DND5E.ContainerMaxDepth", { depth: PhysicalItemTemplate.MAX_DEPTH }));
        return;
      }
    }

    const createItemData = async (item, containerId, depth) => {
      const o = { container: containerId, depth };
      let newItemData = transformAll ? await transformAll(item, o) : item;
      if ( transformFirst && (depth === 0) ) newItemData = await transformFirst(newItemData, o);
      if ( !newItemData ) return;
      if ( newItemData instanceof Item ) newItemData = game.items.fromCompendium(newItemData, {
        clearSort: false, keepId: true, clearOwnership: false
      });
      foundry.utils.mergeObject(newItemData, {"system.container": containerId} );
      if ( !keepId ) newItemData._id = foundry.utils.randomID();

      created.push(newItemData);

      const contents = await item.system.contents;
      if ( contents && (depth < PhysicalItemTemplate.MAX_DEPTH) ) {
        for ( const doc of contents ) await createItemData(doc, newItemData._id, depth + 1);
      }
    };

    const created = [];
    for ( const item of items ) await createItemData(item, container?.id, depth);
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Configuration options for spell scroll creation.
   *
   * @typedef {object} SpellScrollConfiguration
   * @property {boolean} [dialog=true]                           Present scroll creation dialog?
   * @property {"full"|"reference"|"none"} [explanation="full"]  Length of spell scroll rules text to include.
   * @property {number} [level]                                  Level at which the spell should be cast.
   * @property {Partial<SpellScrollValues>} [values]             Spell scroll DC and attack bonus.
   */

  /**
   * Create a consumable spell scroll Item from a spell Item.
   * @param {Item5e|object} spell                   The spell or item data to be made into a scroll.
   * @param {object} [options]                      Additional options that modify the created scroll.
   * @param {SpellScrollConfiguration} [config={}]  Configuration options for scroll creation.
   * @returns {Promise<Item5e|void>}                The created scroll consumable item.
   */
  static async createScrollFromSpell(spell, options={}, config={}) {
    if ( spell.pack ) return this.createScrollFromCompendiumSpell(spell.uuid, config);

    const values = {};
    if ( (spell instanceof Item5e) && spell.isOwned && (game.settings.get("dnd5e", "rulesVersion") === "modern") ) {
      const spellcastingClass = spell.actor.spellcastingClasses?.[spell.system.sourceClass];
      if ( spellcastingClass ) {
        values.bonus = spellcastingClass.spellcasting.attack;
        values.dc = spellcastingClass.spellcasting.save;
      } else {
        values.bonus = spell.actor.system.attributes?.spell?.mod;
        values.dc = spell.actor.system.attributes?.spell?.dc;
      }
    }

    config = foundry.utils.mergeObject({
      explanation: game.user.getFlag("dnd5e", "creation.scrollExplanation") ?? "reference",
      level: spell.system.level,
      values
    }, config);

    if ( config.dialog !== false ) {
      const result = await CreateScrollDialog.create(spell, config);
      if ( !result ) return;
      foundry.utils.mergeObject(config, result);
      await game.user.setFlag("dnd5e", "creation.scrollExplanation", config.explanation);
    }

    // Get spell data
    const itemData = (spell instanceof Item5e) ? spell.toObject() : spell;
    const flags = itemData.flags ?? {};
    if ( Number.isNumeric(config.level) ) {
      flags.dnd5e ??= {};
      flags.dnd5e.scaling = Math.max(0, config.level - spell.system.level);
      flags.dnd5e.spellLevel = {
        value: config.level,
        base: spell.system.level
      };
      itemData.system.level = config.level;
    }

    /**
     * A hook event that fires before the item data for a scroll is created.
     * @function dnd5e.preCreateScrollFromSpell
     * @memberof hookEvents
     * @param {object} itemData                  The initial item data of the spell to convert to a scroll.
     * @param {object} options                   Additional options that modify the created scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     * @returns {boolean}                        Explicitly return false to prevent the scroll to be created.
     */
    if ( Hooks.call("dnd5e.preCreateScrollFromSpell", itemData, options, config) === false ) return;

    let { activities, level, properties, source } = itemData.system;

    // Get scroll data
    let scrollUuid;
    const id = CONFIG.DND5E.spellScrollIds[level];
    if ( foundry.data.validators.isValidId(id) ) {
      scrollUuid = game.packs.get(CONFIG.DND5E.sourcePacks.ITEMS).index.get(id).uuid;
    } else {
      scrollUuid = id;
    }
    const scrollItem = await fromUuid(scrollUuid);
    const scrollData = game.items.fromCompendium(scrollItem);

    // Create a composite description from the scroll description and the spell details
    const desc = this._createScrollDescription(scrollItem, itemData, null, config);

    for ( const level of Array.fromRange(itemData.system.level + 1).reverse() ) {
      const values = CONFIG.DND5E.spellScrollValues[level];
      if ( values ) {
        config.values.bonus ??= values.bonus;
        config.values.dc ??= values.dc;
        break;
      }
    }

    // Apply inferred spell activation, duration, range, and target data to activities
    for ( const activity of Object.values(activities) ) {
      for ( const key of ["activation", "duration", "range", "target"] ) {
        if ( activity[key]?.override !== false ) continue;
        activity[key].override = true;
        foundry.utils.mergeObject(activity[key], itemData.system[key]);
      }
      activity.consumption.targets.push({ type: "itemUses", target: "", value: "1" });
      if ( activity.type === "attack" ) {
        activity.attack.flat = true;
        activity.attack.bonus = values.bonus;
      } else if ( activity.type === "save" ) {
        activity.save.dc.calculation = "";
        activity.save.dc.formula = values.dc;
      }
    }

    // Create the spell scroll data
    const spellScrollData = foundry.utils.mergeObject(scrollData, {
      name: `${game.i18n.localize("DND5E.SpellScroll")}: ${itemData.name}`,
      effects: itemData.effects ?? [],
      flags,
      system: {
        activities, description: { value: desc.trim() }, properties, source
      }
    });
    foundry.utils.mergeObject(spellScrollData, options);
    spellScrollData.system.properties = [
      "mgc",
      ...scrollData.system.properties,
      ...properties ?? [],
      ...options.system?.properties ?? []
    ];

    /**
     * A hook event that fires after the item data for a scroll is created but before the item is returned.
     * @function dnd5e.createScrollFromSpell
     * @memberof hookEvents
     * @param {Item5e|object} spell              The spell or item data to be made into a scroll.
     * @param {object} spellScrollData           The final item data used to make the scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     */
    Hooks.callAll("dnd5e.createScrollFromSpell", spell, spellScrollData, config);

    return new this(spellScrollData);
  }

  /* -------------------------------------------- */

  /**
   * Create a consumable spell scroll Item from a spell Item.
   * @param {string} uuid                           UUID of the spell to add to the scroll.
   * @param {SpellScrollConfiguration} [config={}]  Configuration options for scroll creation.
   * @returns {Promise<Item5e|void>}                The created scroll consumable item.
   */
  static async createScrollFromCompendiumSpell(uuid, config={}) {
    const spell = await fromUuid(uuid);
    if ( !spell ) return;

    const values = {};

    config = foundry.utils.mergeObject({
      explanation: game.user.getFlag("dnd5e", "creation.scrollExplanation") ?? "reference",
      level: spell.system.level,
      values
    }, config);

    if ( config.dialog !== false ) {
      const result = await CreateScrollDialog.create(spell, config);
      if ( !result ) return;
      foundry.utils.mergeObject(config, result);
      await game.user.setFlag("dnd5e", "creation.scrollExplanation", config.explanation);
    }

    /**
     * A hook event that fires before the item data for a scroll is created for a compendium spell.
     * @function dnd5e.preCreateScrollFromCompendiumSpell
     * @memberof hookEvents
     * @param {Item5e} spell                     Spell to add to the scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     * @returns {boolean}                        Explicitly return `false` to prevent the scroll to be created.
     */
    if ( Hooks.call("dnd5e.preCreateScrollFromCompendiumSpell", spell, config) === false ) return;

    // Get scroll data
    let scrollUuid;
    const id = CONFIG.DND5E.spellScrollIds[spell.system.level];
    if ( foundry.data.validators.isValidId(id) ) {
      scrollUuid = game.packs.get(CONFIG.DND5E.sourcePacks.ITEMS).index.get(id).uuid;
    } else {
      scrollUuid = id;
    }
    const scrollItem = await fromUuid(scrollUuid);
    const scrollData = game.items.fromCompendium(scrollItem);

    for ( const level of Array.fromRange(spell.system.level + 1).reverse() ) {
      const values = CONFIG.DND5E.spellScrollValues[level];
      if ( values ) {
        config.values.bonus ??= values.bonus;
        config.values.dc ??= values.dc;
        break;
      }
    }

    const activity = {
      _id: staticID("dnd5escrollspell"),
      type: "cast",
      consumption: {
        targets: [{ type: "itemUses", value: "1" }]
      },
      spell: {
        challenge: {
          attack: config.values.bonus,
          save: config.values.dc,
          override: true
        },
        level: config.level,
        uuid
      }
    };

    // Create the spell scroll data
    const spellScrollData = foundry.utils.mergeObject(scrollData, {
      name: `${game.i18n.localize("DND5E.SpellScroll")}: ${spell.name}`,
      system: {
        activities: { ...(scrollData.system.activities ?? {}), [activity._id]: activity },
        description: {
          value: this._createScrollDescription(scrollItem, spell, `<p>@Embed[${uuid} inline]</p>`, config).trim()
        }
      }
    });

    /**
     * A hook event that fires after the item data for a scroll is created but before the item is returned.
     * @function dnd5e.createScrollFromSpell
     * @memberof hookEvents
     * @param {Item5e} spell                     The spell or item data to be made into a scroll.
     * @param {object} spellScrollData           The final item data used to make the scroll.
     * @param {SpellScrollConfiguration} config  Configuration options for scroll creation.
     */
    Hooks.callAll("dnd5e.createScrollFromSpell", spell, spellScrollData, config);

    return new this(spellScrollData);
  }

  /* -------------------------------------------- */

  /**
   * Create the description for a spell scroll.
   * @param {Item5e} scroll                         Base spell scroll.
   * @param {Item5e|object} spell                   Spell being added to the scroll.
   * @param {string} [spellDescription]             Description from the spell being added.
   * @param {SpellScrollConfiguration} [config={}]  Configuration options for scroll creation.
   * @returns {string}
   * @protected
   */
  static _createScrollDescription(scroll, spell, spellDescription, config={}) {
    spellDescription ??= spell.system.description.value;
    const isConc = spell.system.properties[spell instanceof Item5e ? "has" : "includes"]("concentration");
    const level = spell.system.level;
    switch ( config.explanation ) {
      case "full":
        // Split the scroll description into an intro paragraph and the remaining details
        const scrollDescription = scroll.system.description.value;
        const pdel = "</p>";
        const scrollIntroEnd = scrollDescription.indexOf(pdel);
        const scrollIntro = scrollDescription.slice(0, scrollIntroEnd + pdel.length);
        const scrollDetails = scrollDescription.slice(scrollIntroEnd + pdel.length);
        return [
          scrollIntro,
          `<h3>${spell.name} (${game.i18n.format("DND5E.LevelNumber", { level })})</h3>`,
          isConc ? `<p><em>${game.i18n.localize("DND5E.Scroll.RequiresConcentration")}</em></p>` : null,
          spellDescription,
          `<h3>${game.i18n.localize("DND5E.Scroll.Details")}</h3>`,
          scrollDetails
        ].filterJoin("");
      case "reference":
        return [
          "<p><em>",
          CONFIG.DND5E.spellLevels[level] ?? level,
          " &Reference[Spell Scroll]",
          isConc ? `, ${game.i18n.localize("DND5E.Scroll.RequiresConcentration")}` : null,
          "</em></p>",
          spellDescription
        ].filterJoin("");
    }
    return spellDescription;
  }

  /* -------------------------------------------- */

  /**
   * Spawn a dialog for creating a new Item.
   * @param {object} [data]  Data to pre-populate the Item with.
   * @param {object} [context]
   * @param {Actor5e} [context.parent]       A parent for the Item.
   * @param {string|null} [context.pack]     A compendium pack the Item should be placed in.
   * @param {string[]|null} [context.types]  A list of types to restrict the choices to, or null for no restriction.
   * @returns {Promise<Item5e|null>}
   */
  static async createDialog(data={}, { parent=null, pack=null, types=null, ...options }={}) {
    types ??= game.documentTypes[this.documentName].filter(t => (t !== CONST.BASE_DOCUMENT_TYPE) && (t !== "backpack"));
    if ( !types.length ) return null;
    const collection = parent ? null : pack ? game.packs.get(pack) : game.collections.get(this.documentName);
    const folders = collection?._formatFolderSelectOptions() ?? [];
    const label = game.i18n.localize(this.metadata.label);
    const title = game.i18n.format("DOCUMENT.Create", { type: label });
    const name = data.name || game.i18n.format("DOCUMENT.New", { type: label });
    let type = data.type || CONFIG[this.documentName]?.defaultType;
    if ( !types.includes(type) ) type = types[0];
    const content = await renderTemplate("systems/dnd5e/templates/apps/document-create.hbs", {
      folders, name, type,
      folder: data.folder,
      hasFolders: folders.length > 0,
      types: types.reduce((arr, type) => {
        const label = CONFIG[this.documentName]?.typeLabels?.[type] ?? type;
        arr.push({
          type,
          label: game.i18n.has(label) ? game.i18n.localize(label) : type,
          icon: this.getDefaultArtwork({ type })?.img ?? "icons/svg/item-bag.svg"
        });
        return arr;
      }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
    });
    return Dialog.prompt({
      title, content,
      label: title,
      render: html => {
        const app = html.closest(".app");
        const folder = app.querySelector("select");
        if ( folder ) app.querySelector(".dialog-buttons").insertAdjacentElement("afterbegin", folder);
        app.querySelectorAll(".window-header .header-button").forEach(btn => {
          const label = btn.innerText;
          const icon = btn.querySelector("i");
          btn.innerHTML = icon.outerHTML;
          btn.dataset.tooltip = label;
          btn.setAttribute("aria-label", label);
        });
        app.querySelector(".document-name").select();
      },
      callback: html => {
        const form = html.querySelector("form");
        const fd = new FormDataExtended(form);
        const createData = foundry.utils.mergeObject(data, fd.object, { inplace: false });
        if ( !createData.folder ) delete createData.folder;
        if ( !createData.name?.trim() ) createData.name = this.defaultName();
        return this.create(createData, { parent, pack, renderSheet: true });
      },
      rejectClose: false,
      options: { ...options, jQuery: false, width: 350, classes: ["dnd5e2", "create-document", "dialog"] }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getDefaultArtwork(itemData={}) {
    const { type } = itemData;
    const { img } = super.getDefaultArtwork(itemData);
    return { img: CONFIG.DND5E.defaultArtwork.Item[type] ?? img };
  }

  /* -------------------------------------------- */
  /*  Migrations & Deprecations                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    source = super.migrateData(source);
    ActivitiesTemplate.initializeActivities(source);
    if ( source.type === "class" ) ClassData._migrateTraitAdvancement(source);
    else if ( source.type === "container" ) ContainerData._migrateWeightlessData(source);
    else if ( source.type === "equipment" ) EquipmentData._migrateStealth(source);
    else if ( source.type === "spell" ) SpellData._migrateComponentData(source);
    return source;
  }
}

/**
 * Extend the base Actor class to implement additional system-specific logic.
 */
class Actor5e extends SystemDocumentMixin(Actor) {

  /**
   * The data source for Actor5e.classes allowing it to be lazily computed.
   * @type {Record<string, Item5e>}
   * @private
   */
  _classes;

  /**
   * Cached spellcasting classes.
   * @type {Record<string, Item5e>}
   * @private
   */
  _spellcastingClasses;

  /**
   * Mapping of item compendium source UUIDs to the items.
   * @type {Map<string, Item5e>}
   */
  sourcedItems = this.sourcedItems;

  /* -------------------------------------------- */

  /**
   * Types that can be selected within the compendium browser.
   * @param {object} [options={}]
   * @param {Set<string>} [options.chosen]  Types that have been selected.
   * @returns {SelectChoices}
   */
  static compendiumBrowserTypes({ chosen=new Set() }={}) {
    return new SelectChoices(Actor.TYPES.filter(t => t !== CONST.BASE_DOCUMENT_TYPE).reduce((obj, type) => {
      obj[type] = {
        label: CONFIG.Actor.typeLabels[type],
        chosen: chosen.has(type)
      };
      return obj;
    }, {}));
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * A mapping of classes belonging to this Actor.
   * @type {Record<string, Item5e>}
   */
  get classes() {
    if ( this._classes !== undefined ) return this._classes;
    if ( !["character", "npc"].includes(this.type) ) return this._classes = {};
    return this._classes = Object.fromEntries(this.itemTypes.class.map(cls => [cls.identifier, cls]));
  }

  /* -------------------------------------------- */

  /**
   * Calculate the bonus from any cover the actor is affected by.
   * @type {number}     The cover bonus to AC and dexterity saving throws.
   */
  get coverBonus() {
    const { coverHalf, coverThreeQuarters } = CONFIG.DND5E.statusEffects;
    if ( this.statuses.has("coverThreeQuarters") ) return coverThreeQuarters?.coverBonus;
    else if ( this.statuses.has("coverHalf") ) return coverHalf?.coverBonus;
    return 0;
  }

  /* -------------------------------------------- */

  /**
   * Get all classes which have spellcasting ability.
   * @type {Record<string, Item5e>}
   */
  get spellcastingClasses() {
    if ( this._spellcastingClasses !== undefined ) return this._spellcastingClasses;
    return this._spellcastingClasses = Object.entries(this.classes).reduce((obj, [identifier, cls]) => {
      if ( cls.spellcasting && (cls.spellcasting.progression !== "none") ) obj[identifier] = cls;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Is this Actor currently polymorphed into some other creature?
   * @type {boolean}
   */
  get isPolymorphed() {
    return this.getFlag("dnd5e", "isPolymorphed") || false;
  }

  /* -------------------------------------------- */

  /**
   * The Actor's currently equipped armor, if any.
   * @type {Item5e|null}
   */
  get armor() {
    return this.system.attributes?.ac?.equippedArmor ?? null;
  }

  /* -------------------------------------------- */

  /**
   * The Actor's currently equipped shield, if any.
   * @type {Item5e|null}
   */
  get shield() {
    return this.system.attributes?.ac?.equippedShield ?? null;
  }

  /* -------------------------------------------- */

  /**
   * The items this actor is concentrating on, and the relevant effects.
   * @type {{items: Set<Item5e>, effects: Set<ActiveEffect5e>}}
   */
  get concentration() {
    const concentration = {
      items: new Set(),
      effects: new Set()
    };

    const limit = this.system.attributes?.concentration?.limit ?? 0;
    if ( !limit ) return concentration;

    for ( const effect of this.effects ) {
      if ( !effect.statuses.has(CONFIG.specialStatusEffects.CONCENTRATING) ) continue;
      const data = effect.getFlag("dnd5e", "item");
      concentration.effects.add(effect);
      if ( data ) {
        let item = this.items.get(data.id);
        if ( !item && (foundry.utils.getType(data.data) === "Object") ) {
          item = new Item.implementation(data.data, { keepId: true, parent: this });
        }
        if ( item ) concentration.items.add(item);
      }
    }
    return concentration;
  }

  /* -------------------------------------------- */

  /**
   * Creatures summoned by this actor.
   * @type {Actor5e[]}
   */
  get summonedCreatures() {
    return dnd5e.registry.summons.creatures(this);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(source, options={}) {
    source = super._initializeSource(source, options);
    if ( !source._id || !options.pack || dnd5e.moduleArt.suppressArt ) return source;
    const uuid = `Compendium.${options.pack}.${source._id}`;
    const art = game.dnd5e.moduleArt.map.get(uuid);
    if ( art?.actor || art?.token ) {
      if ( art.actor ) source.img = art.actor;
      if ( typeof art.token === "string" ) source.prototypeToken.texture.src = art.token;
      else if ( art.token ) foundry.utils.mergeObject(source.prototypeToken, art.token);
      const biography = source.system.details?.biography;
      if ( art.credit && biography ) {
        if ( typeof biography.value !== "string" ) biography.value = "";
        biography.value += `<p>${art.credit}</p>`;
      }
    }
    return source;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    if ( this.system.modelProvider !== dnd5e ) return super.prepareData();
    this._clearCachedValues();
    this._preparationWarnings = [];
    super.prepareData();
    this.items.forEach(item => item.prepareFinalAttributes());
    this._prepareSpellcasting();
  }

  /* --------------------------------------------- */

  /**
   * Clear cached class collections.
   * @internal
   */
  _clearCachedValues() {
    this._classes = undefined;
    this._spellcastingClasses = undefined;
  }

  /* --------------------------------------------- */

  /** @inheritDoc */
  prepareEmbeddedDocuments() {
    this.sourcedItems = new SourcedItemsMap();
    this._embeddedPreparation = true;
    super.prepareEmbeddedDocuments();
    delete this._embeddedPreparation;
  }

  /* --------------------------------------------- */

  /** @inheritDoc */
  applyActiveEffects() {
    if ( this.system?.prepareEmbeddedData instanceof Function ) this.system.prepareEmbeddedData();
    // The Active Effects do not have access to their parent at preparation time, so we wait until this stage to
    // determine whether they are suppressed or not.
    for ( const effect of this.allApplicableEffects() ) {
      effect.determineSuppression();
    }
    return super.applyActiveEffects();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  *allApplicableEffects() {
    for ( const effect of super.allApplicableEffects() ) {
      if ( effect.type === "enchantment" ) continue;
      if ( effect.parent?.getFlag("dnd5e", "riders.effect")?.includes(effect.id) ) continue;
      yield effect;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const origin = this.getFlag("dnd5e", "summon.origin");
    // TODO: Replace with parseUuid once V11 support is dropped
    if ( origin && this.token?.id ) dnd5e.registry.summons.track(origin.split(".Item.")[0], this.uuid);

    if ( (this.system.modelProvider !== dnd5e) || (this.type === "group") ) return;
    this.labels = {};
  }

  /* -------------------------------------------- */

  /**
   * Calculate the DC of a concentration save required for a given amount of damage.
   * @param {number} damage  Amount of damage taken.
   * @returns {number}       DC of the required concentration save.
   */
  getConcentrationDC(damage) {
    return Math.clamp(
      Math.floor(damage / 2), 10, game.settings.get("dnd5e", "rulesVersion") === "modern" ? 30 : Infinity
    );
  }

  /* -------------------------------------------- */

  /**
   * Return the amount of experience required to gain a certain character level.
   * @param {number} level  The desired level.
   * @returns {number}      The XP required.
   */
  getLevelExp(level) {
    const levels = CONFIG.DND5E.CHARACTER_EXP_LEVELS;
    return levels[Math.min(level, levels.length - 1)];
  }

  /* -------------------------------------------- */

  /**
   * Return the amount of experience granted by killing a creature of a certain CR.
   * @param {number|null} cr  The creature's challenge rating.
   * @returns {number|null}   The amount of experience granted per kill.
   */
  getCRExp(cr) {
    if ( cr === null ) return null;
    if ( cr < 1.0 ) return Math.max(200 * cr, 10);
    return CONFIG.DND5E.CR_EXP_LEVELS[cr] ?? Object.values(CONFIG.DND5E.CR_EXP_LEVELS).pop();
  }

  /* -------------------------------------------- */

  /**
   * @inheritdoc
   * @param {object} [options]
   * @param {boolean} [options.deterministic] Whether to force deterministic values for data properties that could be
   *                                          either a die term or a flat term.
   */
  getRollData({ deterministic=false }={}) {
    let data;
    if ( this.system.getRollData ) data = this.system.getRollData({ deterministic });
    else data = {...super.getRollData()};
    data.flags = {...this.flags};
    data.name = this.name;
    data.statuses = {};
    for ( const status of this.statuses ) {
      data.statuses[status] = status === "exhaustion" ? this.system.attributes?.exhaustion ?? 1 : 1;
    }
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Is this actor under the effect of this property from some status or due to its level of exhaustion?
   * @param {string} key      A key in `DND5E.conditionEffects`.
   * @returns {boolean}       Whether the actor is affected.
   */
  hasConditionEffect(key) {
    const props = CONFIG.DND5E.conditionEffects[key] ?? new Set();
    const level = this.system.attributes?.exhaustion ?? null;
    const imms = this.system.traits?.ci?.value ?? new Set();
    const applyExhaustion = (level !== null) && !imms.has("exhaustion")
      && (game.settings.get("dnd5e", "rulesVersion") === "legacy");
    const statuses = this.statuses;
    return props.some(k => {
      const l = Number(k.split("-").pop());
      return (statuses.has(k) && !imms.has(k)) || (applyExhaustion && Number.isInteger(l) && (level >= l));
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepares data for a specific skill.
   * @param {string} skillId    The id of the skill to prepare data for.
   * @param {object} [options]  Additional options passed to {@link CreatureTemplate#prepareSkill}.
   * @returns {SkillData}
   * @internal
   */
  _prepareSkill(skillId, options) {
    return this.system.prepareSkill?.(skillId, options) ?? {};
  }

  /* -------------------------------------------- */
  /*  Spellcasting Preparation                    */
  /* -------------------------------------------- */

  /**
   * Prepare data related to the spell-casting capabilities of the Actor.
   * Mutates the value of the system.spells object. Must be called after final item preparation.
   * @protected
   */
  _prepareSpellcasting() {
    if ( !this.system.spells ) return;

    // Translate the list of classes into spellcasting progression
    const progression = { slot: 0, pact: 0 };
    const types = {};

    // Grab all classes with spellcasting
    const classes = this.itemTypes.class.filter(cls => {
      const type = cls.spellcasting.type;
      if ( !type ) return false;
      types[type] ??= 0;
      types[type] += 1;
      return true;
    });

    for ( const cls of classes ) this.constructor.computeClassProgression(
      progression, cls, { actor: this, count: types[cls.spellcasting.type] }
    );

    if ( this.type === "npc" ) {
      if ( progression.slot || progression.pact ) {
        this.system.attributes.spell.level = progression.slot || progression.pact;
      } else progression.slot = this.system.attributes.spell.level ?? 0;
    }

    for ( const type of Object.keys(CONFIG.DND5E.spellcastingTypes) ) {
      this.constructor.prepareSpellcastingSlots(this.system.spells, type, progression, { actor: this });
    }
  }

  /* -------------------------------------------- */

  /**
   * Contribute to the actor's spellcasting progression.
   * @param {object} progression                             Spellcasting progression data. *Will be mutated.*
   * @param {Item5e} cls                                     Class for whom this progression is being computed.
   * @param {object} [config={}]
   * @param {Actor5e|null} [config.actor]                    Actor for whom the data is being prepared.
   * @param {SpellcastingDescription} [config.spellcasting]  Spellcasting descriptive object.
   * @param {number} [config.count=1]                        Number of classes with this type of spellcasting.
   */
  static computeClassProgression(progression, cls, {actor, spellcasting, count=1}={}) {
    const type = cls.spellcasting.type;
    spellcasting = spellcasting ?? cls.spellcasting;

    /**
     * A hook event that fires while computing the spellcasting progression for each class on each actor.
     * The actual hook names include the spellcasting type (e.g. `dnd5e.computeLeveledProgression`).
     * @param {object} progression                    Spellcasting progression data. *Will be mutated.*
     * @param {Actor5e|null} [actor]                  Actor for whom the data is being prepared.
     * @param {Item5e} cls                            Class for whom this progression is being computed.
     * @param {SpellcastingDescription} spellcasting  Spellcasting descriptive object.
     * @param {number} count                          Number of classes with this type of spellcasting.
     * @returns {boolean}  Explicitly return false to prevent default progression from being calculated.
     * @function dnd5e.computeSpellcastingProgression
     * @memberof hookEvents
     */
    const allowed = Hooks.call(
      `dnd5e.compute${type.capitalize()}Progression`, progression, actor, cls, spellcasting, count
    );

    if ( allowed && (type === "pact") ) {
      this.computePactProgression(progression, actor, cls, spellcasting, count);
    } else if ( allowed && (type === "leveled") ) {
      this.computeLeveledProgression(progression, actor, cls, spellcasting, count);
    }
  }

  /* -------------------------------------------- */

  /**
   * Contribute to the actor's spellcasting progression for a class with leveled spellcasting.
   * @param {object} progression                    Spellcasting progression data. *Will be mutated.*
   * @param {Actor5e} actor                         Actor for whom the data is being prepared.
   * @param {Item5e} cls                            Class for whom this progression is being computed.
   * @param {SpellcastingDescription} spellcasting  Spellcasting descriptive object.
   * @param {number} count                          Number of classes with this type of spellcasting.
   */
  static computeLeveledProgression(progression, actor, cls, spellcasting, count) {
    const prog = CONFIG.DND5E.spellcastingTypes.leveled.progression[spellcasting.progression];
    if ( !prog ) return;
    const rounding = prog.roundUp ? Math.ceil : Math.floor;
    progression.slot += rounding(spellcasting.levels / prog.divisor ?? 1);
    // Single-classed, non-full progression rounds up, rather than down.
    if ( (count === 1) && (prog.divisor > 1) && progression.slot ) {
      progression.slot = Math.ceil(spellcasting.levels / prog.divisor);
    }
  }

  /* -------------------------------------------- */

  /**
   * Contribute to the actor's spellcasting progression for a class with pact spellcasting.
   * @param {object} progression                    Spellcasting progression data. *Will be mutated.*
   * @param {Actor5e} actor                         Actor for whom the data is being prepared.
   * @param {Item5e} cls                            Class for whom this progression is being computed.
   * @param {SpellcastingDescription} spellcasting  Spellcasting descriptive object.
   * @param {number} count                          Number of classes with this type of spellcasting.
   */
  static computePactProgression(progression, actor, cls, spellcasting, count) {
    progression.pact += spellcasting.levels;
  }

  /* -------------------------------------------- */

  /**
   * Prepare actor's spell slots using progression data.
   * @param {object} spells           The `data.spells` object within actor's data. *Will be mutated.*
   * @param {string} type             Type of spellcasting slots being prepared.
   * @param {object} progression      Spellcasting progression data.
   * @param {object} [config]
   * @param {Actor5e} [config.actor]  Actor for whom the data is being prepared.
   */
  static prepareSpellcastingSlots(spells, type, progression, {actor}={}) {
    /**
     * A hook event that fires to convert the provided spellcasting progression into spell slots.
     * The actual hook names include the spellcasting type (e.g. `dnd5e.prepareLeveledSlots`).
     * @param {object} spells        The `data.spells` object within actor's data. *Will be mutated.*
     * @param {Actor5e} actor        Actor for whom the data is being prepared.
     * @param {object} progression   Spellcasting progression data.
     * @returns {boolean}            Explicitly return false to prevent default preparation from being performed.
     * @function dnd5e.prepareSpellcastingSlots
     * @memberof hookEvents
     */
    const allowed = Hooks.call(`dnd5e.prepare${type.capitalize()}Slots`, spells, actor, progression);

    if ( allowed && (type === "pact") ) this.preparePactSlots(spells, actor, progression);
    else if ( allowed && (type === "leveled") ) this.prepareLeveledSlots(spells, actor, progression);
  }

  /* -------------------------------------------- */

  /**
   * Prepare leveled spell slots using progression data.
   * @param {object} spells        The `data.spells` object within actor's data. *Will be mutated.*
   * @param {Actor5e} actor        Actor for whom the data is being prepared.
   * @param {object} progression   Spellcasting progression data.
   */
  static prepareLeveledSlots(spells, actor, progression) {
    const levels = Math.clamp(progression.slot, 0, CONFIG.DND5E.maxLevel);
    const slots = CONFIG.DND5E.SPELL_SLOT_TABLE[Math.min(levels, CONFIG.DND5E.SPELL_SLOT_TABLE.length) - 1] ?? [];
    for ( const level of Array.fromRange(Object.keys(CONFIG.DND5E.spellLevels).length - 1, 1) ) {
      const slot = spells[`spell${level}`] ??= { value: 0 };
      slot.label = CONFIG.DND5E.spellLevels[level];
      slot.level = level;
      slot.max = Number.isNumeric(slot.override) ? Math.max(parseInt(slot.override), 0) : slots[level - 1] ?? 0;
      slot.type = "leveled";
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare non-leveled spell slots using progression data.
   * @param {object} spells        The `data.spells` object within actor's data. *Will be mutated.*
   * @param {Actor5e} actor        Actor for whom the data is being prepared.
   * @param {object} progression   Spellcasting progression data.
   * @param {string} key           The internal key for these spell slots on the actor.
   * @param {object} table         The table used for determining the progression of slots.
   */
  static prepareAltSlots(spells, actor, progression, key, table) {
    // Spell data:
    // - x.level: Slot level for casting
    // - x.max: Total number of slots
    // - x.value: Currently available slots
    // - x.override: Override number of available spell slots

    let keyLevel = Math.clamp(progression[key], 0, CONFIG.DND5E.maxLevel);
    spells[key] ??= {};
    spells[key].type = key;
    const override = Number.isNumeric(spells[key].override) ? parseInt(spells[key].override) : null;

    // Slot override
    if ( (keyLevel === 0) && (actor.type === "npc") && (override !== null) ) {
      keyLevel = actor.system.attributes.spell.level;
    }

    const [, keyConfig] = Object.entries(table).reverse().find(([l]) => Number(l) <= keyLevel) ?? [];
    if ( keyConfig ) {
      spells[key].level = keyConfig.level;
      if ( override === null ) spells[key].max = keyConfig.slots;
      else spells[key].max = Math.max(override, 1);
      spells[key].value = Math.min(spells[key].value, spells[key].max);
    }

    else {
      spells[key].max = override || 0;
      spells[key].level = (spells[key].max > 0) ? 1 : 0;
    }
  }

  /* -------------------------------------------- */

  /**
   * Convenience method for preparing pact slots specifically.
   * @param {object} spells        The `data.spells` object within actor's data. *Will be mutated.*
   * @param {Actor5e} actor        Actor for whom the data is being prepared.
   * @param {object} progression   Spellcasting progression data.
   */
  static preparePactSlots(spells, actor, progression) {
    this.prepareAltSlots(spells, actor, progression, "pact", CONFIG.DND5E.pactCastingProgression);
    spells.pact.label = game.i18n.localize("DND5E.PactMagic");
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    const sourceId = this._stats?.compendiumSource;
    if ( sourceId?.startsWith("Compendium.") ) return;

    // Configure prototype token settings
    const prototypeToken = {};
    if ( "size" in (this.system.traits || {}) ) {
      const size = CONFIG.DND5E.actorSizes[this.system.traits.size || "med"].token ?? 1;
      if ( !foundry.utils.hasProperty(data, "prototypeToken.width") ) prototypeToken.width = size;
      if ( !foundry.utils.hasProperty(data, "prototypeToken.height") ) prototypeToken.height = size;
    }
    if ( this.type === "character" ) Object.assign(prototypeToken, {
      sight: { enabled: true }, actorLink: true, disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
    });
    this.updateSource({ prototypeToken });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;

    // Apply changes in Actor size to Token width/height
    if ( "size" in (this.system.traits || {}) ) {
      const newSize = foundry.utils.getProperty(changed, "system.traits.size");
      if ( newSize && (newSize !== this.system.traits?.size) ) {
        let size = CONFIG.DND5E.actorSizes[newSize].token ?? 1;
        if ( !foundry.utils.hasProperty(changed, "prototypeToken.width") ) {
          changed.prototypeToken ||= {};
          changed.prototypeToken.height = size;
          changed.prototypeToken.width = size;
        }
      }
    }

    // Reset death save counters and store hp
    if ( "hp" in (this.system.attributes || {}) ) {
      const isDead = this.system.attributes.hp.value <= 0;
      if ( isDead && (foundry.utils.getProperty(changed, "system.attributes.hp.value") > 0) ) {
        foundry.utils.setProperty(changed, "system.attributes.death.success", 0);
        foundry.utils.setProperty(changed, "system.attributes.death.failure", 0);
      }
      foundry.utils.setProperty(options, "dnd5e.hp", { ...this.system.attributes.hp });
    }

    // Record previous exhaustion level.
    if ( Number.isFinite(foundry.utils.getProperty(changed, "system.attributes.exhaustion")) ) {
      foundry.utils.setProperty(options, "dnd5e.originalExhaustion", this.system.attributes.exhaustion);
    }
  }

  /* -------------------------------------------- */

  /**
   * Assign a class item as the original class for the Actor based on which class has the most levels.
   * @returns {Promise<Actor5e>}  Instance of the updated actor.
   * @protected
   */
  _assignPrimaryClass() {
    const classes = this.itemTypes.class.sort((a, b) => b.system.levels - a.system.levels);
    const newPC = classes[0]?.id || "";
    return this.update({"system.details.originalClass": newPC});
  }

  /* -------------------------------------------- */
  /*  Gameplay Mechanics                          */
  /* -------------------------------------------- */

  /** @override */
  async modifyTokenAttribute(attribute, value, isDelta, isBar) {
    if ( attribute === "attributes.hp" ) {
      const hp = this.system.attributes.hp;
      const delta = isDelta ? (-1 * value) : (hp.value + hp.temp) - value;
      return this.applyDamage(delta);
    } else if ( attribute.startsWith(".") ) {
      const item = fromUuidSync(attribute, { relative: this });
      let newValue = item?.system.uses?.value ?? 0;
      if ( isDelta ) newValue += value;
      else newValue = value;
      return item?.update({ "system.uses.spent": item.system.uses.max - newValue });
    }
    return super.modifyTokenAttribute(attribute, value, isDelta, isBar);
  }

  /* -------------------------------------------- */

  /**
   * Description of a source of damage.
   *
   * @typedef {object} DamageDescription
   * @property {number} value            Amount of damage.
   * @property {string} type             Type of damage.
   * @property {Set<string>} properties  Physical properties that affect damage application.
   * @property {object} [active]
   * @property {number} [active.multiplier]      Final calculated multiplier.
   * @property {boolean} [active.modifications]  Did modification affect this description?
   * @property {boolean} [active.resistance]     Did resistance affect this description?
   * @property {boolean} [active.vulnerability]  Did vulnerability affect this description?
   * @property {boolean} [active.immunity]       Did immunity affect this description?
   */

  /**
   * Options for damage application.
   *
   * @typedef {object} DamageApplicationOptions
   * @property {boolean|Set<string>} [downgrade]  Should this actor's resistances and immunities be downgraded by one
   *                                              step? A set of damage types to be downgraded or `true` to downgrade
   *                                              all damage types.
   * @property {number} [multiplier=1]         Amount by which to multiply all damage.
   * @property {object|boolean} [ignore]       Set to `true` to ignore all damage modifiers. If set to an object, then
   *                                           values can either be `true` to indicate that the all modifications of
   *                                           that type should be ignored, or a set of specific damage types for which
   *                                           it should be ignored.
   * @property {boolean|Set<string>} [ignore.immunity]       Should this actor's damage immunity be ignored?
   * @property {boolean|Set<string>} [ignore.resistance]     Should this actor's damage resistance be ignored?
   * @property {boolean|Set<string>} [ignore.vulnerability]  Should this actor's damage vulnerability be ignored?
   * @property {boolean|Set<string>} [ignore.modification]   Should this actor's damage modification be ignored?
   * @property {boolean} [invertHealing=true]  Automatically invert healing types to it heals, rather than damages.
   * @property {"damage"|"healing"} [only]     Apply only damage or healing parts. Untyped rolls will always be applied.
   */

  /**
   * Apply a certain amount of damage or healing to the health pool for Actor
   * @param {DamageDescription[]|number} damages     Damages to apply.
   * @param {DamageApplicationOptions} [options={}]  Damage application options.
   * @returns {Promise<Actor5e>}                     A Promise which resolves once the damage has been applied.
   */
  async applyDamage(damages, options={}) {
    const hp = this.system.attributes.hp;
    if ( !hp ) return this; // Group actors don't have HP at the moment

    if ( Number.isNumeric(damages) ) {
      damages = [{ value: damages }];
      options.ignore ??= true;
    }

    damages = this.calculateDamage(damages, options);
    if ( !damages ) return this;

    // Round damage towards zero
    let { amount, temp } = damages.reduce((acc, d) => {
      if ( d.type === "temphp" ) acc.temp += d.value;
      else acc.amount += d.value;
      return acc;
    }, { amount: 0, temp: 0 });
    amount = amount > 0 ? Math.floor(amount) : Math.ceil(amount);

    const deltaTemp = amount > 0 ? Math.min(hp.temp, amount) : 0;
    const deltaHP = Math.clamp(amount - deltaTemp, -hp.damage, hp.value);
    const updates = {
      "system.attributes.hp.temp": hp.temp - deltaTemp,
      "system.attributes.hp.value": hp.value - deltaHP
    };

    if ( temp > updates["system.attributes.hp.temp"] ) updates["system.attributes.hp.temp"] = temp;

    /**
     * A hook event that fires before damage is applied to an actor.
     * @param {Actor5e} actor                     Actor the damage will be applied to.
     * @param {number} amount                     Amount of damage that will be applied.
     * @param {object} updates                    Distinct updates to be performed on the actor.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @returns {boolean}                         Explicitly return `false` to prevent damage application.
     * @function dnd5e.preApplyDamage
     * @memberof hookEvents
     */
    if ( Hooks.call("dnd5e.preApplyDamage", this, amount, updates, options) === false ) return this;

    // Delegate damage application to a hook
    // TODO: Replace this in the future with a better modifyTokenAttribute function in the core
    if ( Hooks.call("modifyTokenAttribute", {
      attribute: "attributes.hp",
      value: amount,
      isDelta: false,
      isBar: true
    }, updates) === false ) return this;

    await this.update(updates);

    /**
     * A hook event that fires after damage has been applied to an actor.
     * @param {Actor5e} actor                     Actor that has been damaged.
     * @param {number} amount                     Amount of damage that has been applied.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @function dnd5e.applyDamage
     * @memberof hookEvents
     */
    Hooks.callAll("dnd5e.applyDamage", this, amount, options);

    return this;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the damage that will be applied to this actor.
   * @param {DamageDescription[]} damages            Damages to calculate.
   * @param {DamageApplicationOptions} [options={}]  Damage calculation options.
   * @returns {DamageDescription[]|false}            New damage descriptions with changes applied, or `false` if the
   *                                                 calculation was canceled.
   */
  calculateDamage(damages, options={}) {
    damages = foundry.utils.deepClone(damages);

    /**
     * A hook event that fires before damage amount is calculated for an actor.
     * @param {Actor5e} actor                     The actor being damaged.
     * @param {DamageDescription[]} damages       Damage descriptions.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @returns {boolean}                         Explicitly return `false` to prevent damage application.
     * @function dnd5e.preCalculateDamage
     * @memberof hookEvents
     */
    if ( Hooks.call("dnd5e.preCalculateDamage", this, damages, options) === false ) return false;

    const multiplier = options.multiplier ?? 1;

    const downgrade = type => options.downgrade === true || options.downgrade?.has?.(type);
    const ignore = (category, type, skipDowngrade) => {
      return options.ignore === true
        || options.ignore?.[category] === true
        || options.ignore?.[category]?.has?.(type)
        || ((category === "immunity") && downgrade(type) && !skipDowngrade)
        || ((category === "resistance") && downgrade(type) && !hasEffect("di", type));
    };

    const traits = this.system.traits ?? {};
    const hasEffect = (category, type, properties) => {
      if ( (category === "dr") && downgrade(type) && hasEffect("di", type, properties)
        && !ignore("immunity", type, true) ) return true;
      const config = traits[category];
      if ( !config?.value.has(type) ) return false;
      if ( !CONFIG.DND5E.damageTypes[type]?.isPhysical || !properties?.size ) return true;
      return !config.bypasses?.intersection(properties)?.size;
    };

    const skipped = type => {
      if ( options.only === "damage" ) return type in CONFIG.DND5E.healingTypes;
      if ( options.only === "healing" ) return type in CONFIG.DND5E.damageTypes;
      return false;
    };

    const rollData = this.getRollData({deterministic: true});

    damages.forEach(d => {
      d.active ??= {};

      // Skip damage types with immunity
      if ( skipped(d.type) || (!ignore("immunity", d.type) && hasEffect("di", d.type, d.properties)) ) {
        d.value = 0;
        d.active.multiplier = 0;
        d.active.immunity = true;
        return;
      }

      // Apply type-specific damage reduction
      if ( !ignore("modification", d.type) && traits.dm?.amount[d.type]
        && !traits.dm.bypasses.intersection(d.properties).size ) {
        const modification = simplifyBonus(traits.dm.amount[d.type], rollData);
        if ( Math.sign(d.value) !== Math.sign(d.value + modification) ) d.value = 0;
        else d.value += modification;
        d.active.modification = true;
      }

      let damageMultiplier = multiplier;

      // Apply type-specific damage resistance
      if ( !ignore("resistance", d.type) && hasEffect("dr", d.type, d.properties) ) {
        damageMultiplier /= 2;
        d.active.resistance = true;
      }

      // Apply type-specific damage vulnerability
      if ( !ignore("vulnerability", d.type) && hasEffect("dv", d.type, d.properties) ) {
        damageMultiplier *= 2;
        d.active.vulnerability = true;
      }

      // Negate healing types
      if ( (options.invertHealing !== false) && (d.type === "healing") ) damageMultiplier *= -1;

      d.value = d.value * damageMultiplier;
      d.active.multiplier = (d.active.multiplier ?? 1) * damageMultiplier;
    });

    /**
     * A hook event that fires after damage values are calculated for an actor.
     * @param {Actor5e} actor                     The actor being damaged.
     * @param {DamageDescription[]} damages       Damage descriptions.
     * @param {DamageApplicationOptions} options  Additional damage application options.
     * @returns {boolean}                         Explicitly return `false` to prevent damage application.
     * @function dnd5e.calculateDamage
     * @memberof hookEvents
     */
    if ( Hooks.call("dnd5e.calculateDamage", this, damages, options) === false ) return false;

    return damages;
  }

  /* -------------------------------------------- */

  /**
   * Apply a certain amount of temporary hit point, but only if it's more than the actor currently has.
   * @param {number} amount       An amount of temporary hit points to set
   * @returns {Promise<Actor5e>}  A Promise which resolves once the temp HP has been applied
   */
  async applyTempHP(amount=0) {
    amount = parseInt(amount);
    const hp = this.system.attributes.hp;

    // Update the actor if the new amount is greater than the current
    const tmp = parseInt(hp.temp) || 0;
    return amount > tmp ? this.update({"system.attributes.hp.temp": amount}) : this;
  }

  /* -------------------------------------------- */

  /**
   * Get a color used to represent the current hit points of an Actor.
   * @param {number} current        The current HP value
   * @param {number} max            The maximum HP value
   * @returns {Color}               The color used to represent the HP percentage
   */
  static getHPColor(current, max) {
    const pct = Math.clamp(current, 0, max) / max;
    return Color.fromRGB([(1-(pct/2)), pct, 0]);
  }

  /* -------------------------------------------- */

  /**
   * Initiate concentration on an item.
   * @param {Activity} activity                  The activity on which to being concentration.
   * @param {object} [effectData]                Effect data to merge into the created effect.
   * @returns {Promise<ActiveEffect5e|void>}     A promise that resolves to the created effect.
   */
  async beginConcentrating(activity, effectData={}) {
    if ( activity instanceof Item ) {
      foundry.utils.logCompatibilityWarning(
        "The `beginConcentrating` method on Actor5e now takes an Activity, rather than an Item.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      activity = activity.system.activities?.contents[0];
      if ( !activity ) return;
    }

    effectData = ActiveEffect5e.createConcentrationEffectData(activity, effectData);

    /**
     * A hook that is called before a concentration effect is created.
     * @function dnd5e.preBeginConcentrating
     * @memberof hookEvents
     * @param {Actor5e} actor         The actor initiating concentration.
     * @param {Item5e} item           The item that will be concentrated on.
     * @param {object} effectData     Data used to create the ActiveEffect.
     * @param {Activity} activity     The activity that triggered the concentration.
     * @returns {boolean}             Explicitly return false to prevent the effect from being created.
     */
    if ( Hooks.call("dnd5e.preBeginConcentrating", this, activity.item, effectData, activity) === false ) return;

    const effect = await ActiveEffect5e.create(effectData, { parent: this });

    /**
     * A hook that is called after a concentration effect is created.
     * @function dnd5e.createConcentrating
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor initiating concentration.
     * @param {Item5e} item               The item that is being concentrated on.
     * @param {ActiveEffect5e} effect     The created ActiveEffect instance.
     * @param {Activity} activity         The activity that triggered the concentration.
     */
    Hooks.callAll("dnd5e.beginConcentrating", this, activity.item, effect, activity);

    return effect;
  }

  /* -------------------------------------------- */

  /**
   * End concentration on an item.
   * @param {Item5e|ActiveEffect5e|string} [target]    An item or effect to end concentration on, or id of an effect.
   *                                                   If not provided, all maintained effects are removed.
   * @returns {Promise<ActiveEffect5e[]>}              A promise that resolves to the deleted effects.
   */
  async endConcentration(target) {
    let effect;
    const { effects } = this.concentration;

    if ( !target ) {
      return effects.reduce(async (acc, effect) => {
        acc = await acc;
        return acc.concat(await this.endConcentration(effect));
      }, []);
    }

    if ( foundry.utils.getType(target) === "string" ) effect = effects.find(e => e.id === target);
    else if ( target instanceof ActiveEffect5e ) effect = effects.has(target) ? target : null;
    else if ( target instanceof Item5e ) {
      effect = effects.find(e => {
        const data = e.getFlag("dnd5e", "item") ?? {};
        return (data.id === target._id) || (data.data?._id === target._id);
      });
    }
    if ( !effect ) return [];

    /**
     * A hook that is called before a concentration effect is deleted.
     * @function dnd5e.preEndConcentration
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor ending concentration.
     * @param {ActiveEffect5e} effect     The ActiveEffect that will be deleted.
     * @returns {boolean}                 Explicitly return false to prevent the effect from being deleted.
     */
    if ( Hooks.call("dnd5e.preEndConcentration", this, effect) === false) return [];

    await effect.delete();

    /**
     * A hook that is called after a concentration effect is deleted.
     * @function dnd5e.endConcentration
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor ending concentration.
     * @param {ActiveEffect5e} effect     The ActiveEffect that was deleted.
     */
    Hooks.callAll("dnd5e.endConcentration", this, effect);

    return [effect];
  }

  /* -------------------------------------------- */

  /**
   * Create a chat message for this actor with a prompt to challenge concentration.
   * @param {object} [options]
   * @param {number} [options.dc]         The target value of the saving throw.
   * @param {string} [options.ability]    An ability to use instead of the default.
   * @returns {Promise<ChatMessage5e>}    A promise that resolves to the created chat message.
   */
  async challengeConcentration({ dc=10, ability=null }={}) {
    const isConcentrating = this.concentration.effects.size > 0;
    if ( !isConcentrating ) return null;

    const dataset = {
      action: "concentration",
      dc: dc
    };
    if ( ability in CONFIG.DND5E.abilities ) dataset.ability = ability;

    const config = {
      type: "concentration",
      format: "short",
      icon: true
    };

    return ChatMessage.implementation.create({
      content: await renderTemplate("systems/dnd5e/templates/chat/request-card.hbs", {
        buttons: [{
          dataset: { ...dataset, type: "concentration", visbility: "all" },
          buttonLabel: createRollLabel({ ...dataset, ...config }),
          hiddenLabel: createRollLabel({ ...dataset, ...config, hideDC: true })
        }]
      }),
      whisper: game.users.filter(user => this.testUserPermission(user, "OWNER")),
      speaker: ChatMessage.implementation.getSpeaker({ actor: this })
    });
  }

  /* -------------------------------------------- */

  /**
   * Determine whether the provided ability is usable for remarkable athlete.
   * @param {string} ability  Ability type to check.
   * @returns {boolean}       Whether the actor has the remarkable athlete flag and the ability is physical.
   * @private
   */
  _isRemarkableAthlete(ability) {
    return (game.settings.get("dnd5e", "rulesVersion") === "legacy") && this.getFlag("dnd5e", "remarkableAthlete")
      && CONFIG.DND5E.characterFlags.remarkableAthlete.abilities.includes(ability);
  }

  /* -------------------------------------------- */
  /*  Rolling                                     */
  /* -------------------------------------------- */

  /**
   * Add the reduction to this roll from exhaustion if using the modern rules.
   * @param {string[]} parts  Roll parts.
   * @param {object} data     Roll data.
   */
  addRollExhaustion(parts, data) {
    if ( (game.settings.get("dnd5e", "rulesVersion") !== "modern") || !this.system.attributes?.exhaustion ) return;
    const amount = this.system.attributes.exhaustion * (CONFIG.DND5E.conditionTypes.exhaustion?.reduction?.rolls ?? 0);
    if ( amount ) {
      parts.push("@exhaustion");
      data.exhaustion = -amount;
    }
  }

  /* -------------------------------------------- */

  /**
   * Roll an ability check with a skill.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<SkillToolRollDialogConfiguration>} dialog   Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message     Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                          A Promise which resolves to the created Roll instance.
   */
  async rollSkill(config={}, dialog={}, message={}) {
    const skillLabel = CONFIG.DND5E.skills[config.skill]?.label ?? "";
    const ability = this.system.skills[config.skill]?.ability ?? CONFIG.DND5E.skills[config.skill]?.ability ?? "";
    const abilityLabel = CONFIG.DND5E.abilities[ability]?.label ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.SkillPromptTitle", { skill: skillLabel, ability: abilityLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollSkillTool("skill", config, dialogConfig, message);
  }

  /* -------------------------------------------- */

  /**
   * Roll an ability check with a tool.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<SkillToolRollDialogConfiguration>} dialog   Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message     Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                          A Promise which resolves to the created Roll instance.
   */
  async rollToolCheck(config={}, dialog={}, message={}) {
    const toolLabel = keyLabel(config.tool, { trait: "tool" }) ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.ToolPromptTitle", { tool: toolLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollSkillTool("tool", config, dialogConfig, message);
  }

  /* -------------------------------------------- */

  /**
   * @typedef {D20RollProcessConfiguration} SkillToolRollProcessConfiguration
   * @property {string} [ability]  The ability to be rolled with the skill.
   * @property {string} [bonus]    Additional bonus term added to the check.
   * @property {Item5e} [item]     Tool item used for rolling.
   * @property {string} [skill]    The skill to roll.
   * @property {string} [tool]     The tool to roll.
   */

  /**
   * @typedef {BasicRollDialogConfiguration} SkillToolRollDialogConfiguration
   * @property {SkillToolRollConfigurationDialogOptions} [options]  Configuration options.
   */

  /**
   * Shared rolling functionality between skill & tool checks.
   * @param {"skill"|"tool"} type                                Type of roll.
   * @param {Partial<SkillToolRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<SkillToolRollDialogConfiguration>} dialog   Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message     Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                          A Promise which resolves to the created Roll instance.
   */
  async #rollSkillTool(type, config={}, dialog={}, message={}) {
    let oldFormat = false;
    const name = type === "skill" ? "Skill" : "ToolCheck";

    // Handle deprecated calling pattern
    if ( foundry.utils.getType(config) !== "Object" ) {
      foundry.utils.logCompatibilityWarning(
        `The \`roll${name}\` method on Actor5e now takes roll, dialog, and message config objects as parameters.`,
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      oldFormat = true;
      const oldConfig = dialog;
      config = { [type]: config };
      if ( oldConfig.ability ) config.ability = oldConfig.ability;
      if ( oldConfig.bonus ) config.bonus = oldConfig.bonus;
      dialog = {};
      _applyDeprecatedD20Configs(config, dialog, message, oldConfig);
    }

    const skillConfig = CONFIG.DND5E.skills[config.skill];
    const toolConfig = CONFIG.DND5E.tools[config.tool];
    if ( ((type === "skill") && !skillConfig) || ((type === "tool") && !toolConfig) ) {
      return this.rollAbilityTest(config, dialog, message);
    }

    const relevant = type === "skill" ? this.system.skills?.[config.skill] : this.system.tools?.[config.tool];
    const buildConfig = this._buildSkillToolConfig.bind(this, type);

    const rollConfig = foundry.utils.mergeObject({
      ability: relevant?.ability ?? (type === "skill" ? skillConfig.ability : toolConfig.ability),
      advantage: relevant?.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE,
      disadvantage: relevant?.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE,
      halflingLucky: this.getFlag("dnd5e", "halflingLucky"),
      reliableTalent: (relevant?.value >= 1) && this.getFlag("dnd5e", "reliableTalent")
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), type, "abilityCheck", "d20Test"];
    rollConfig.rolls = [BasicRoll.mergeConfigs({
      options: {
        maximum: relevant?.roll.max,
        minimum: relevant?.roll.min
      }
    }, config.rolls?.shift())].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      applicationClass: SkillToolRollConfigurationDialog,
      options: {
        buildConfig,
        chooseAbility: true
      }
    }, dialog);

    const abilityLabel = CONFIG.DND5E.abilities[relevant?.ability ?? skillConfig?.ability ?? ""]?.label;

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flags: {
          dnd5e: {
            messageType: "roll",
            roll: {
              [`${type}Id`]: config[type],
              type
            }
          }
        },
        flavor: type === "skill"
          ? game.i18n.format("DND5E.SkillPromptTitle", { skill: skillConfig.label, ability: abilityLabel })
          : game.i18n.format("DND5E.ToolPromptTitle", { tool: keyLabel(config.tool, { trait: "tool" }) ?? "" }),
        speaker: ChatMessage.getSpeaker({ actor: this })
      }
    }, message);

    if ( `dnd5e.preRoll${name}` in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        `The \`dnd5e.preRoll${name}\` hook has been deprecated and replaced with \`dnd5e.preRoll${type.capitalize()}V2\`.`,
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      const oldConfig = _createDeprecatedD20Config(rollConfig, dialogConfig, messageConfig);
      if ( Hooks.call(`dnd5e.preRoll${name}`, this, oldConfig, config.skill) === false ) return null;
      _applyDeprecatedD20Configs(rollConfig, dialogConfig, messageConfig, oldConfig);
    }

    const rolls = await CONFIG.Dice.D20Roll.build(rollConfig, dialogConfig, messageConfig);
    if ( !rolls.length ) return null;

    /**
     * A hook event that fires after a skill or tool check has been rolled.
     * @function dnd5e.rollSkillV2
     * @function dnd5e.rollToolCheckV2
     * @memberof hookEvents
     * @param {D20Roll[]} rolls       The resulting rolls.
     * @param {object} data
     * @param {string} [data.skill]   ID of the skill that was rolled as defined in `CONFIG.DND5E.skills`.
     * @param {string} [data.tool]    ID of the tool that was rolled as defined in `CONFIG.DND5E.tools`.
     * @param {Actor5e} data.subject  Actor for which the roll has been performed.
     */
    Hooks.callAll(`dnd5e.roll${name}V2`, rolls, { [type]: config[type], subject: this });

    if ( `dnd5e.roll${name}` in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        `The \`dnd5e.roll${name}\` hook has been deprecated and replaced with \`dnd5e.roll${type.capitalize()}V2\`.`,
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      Hooks.callAll(`dnd5e.roll${name}`, this, rolls[0], config.skill);
    }

    return oldFormat ? rolls[0] : rolls;
  }

  /* -------------------------------------------- */

  /**
   * Configure a roll config for each roll performed as part of the skill or tool check process. Will be called once
   * per roll in the process each time an option is changed in the roll configuration interface.
   * @param {"skill"|"tool"} type                          Type of roll.
   * @param {D20RollProcessConfiguration} process          Configuration for the entire rolling process.
   * @param {D20RollConfiguration} config                  Configuration for a specific roll.
   * @param {FormDataExtended} [formData]                  Any data entered into the rolling prompt.
   * @param {number} index                                 Index of the roll within all rolls being prepared.
   */
  _buildSkillToolConfig(type, process, config, formData, index) {
    const relevant = type === "skill" ? this.system.skills?.[process.skill] : this.system.tools?.[process.tool];
    const rollData = this.getRollData();
    const abilityId = formData?.get("ability") ?? process.ability;
    const ability = this.system.abilities?.[abilityId];
    const prof = this.system.calculateAbilityCheckProficiency(relevant?.effectValue ?? 0, abilityId);

    let { parts, data } = CONFIG.Dice.BasicRoll.constructParts({
      mod: ability?.mod,
      prof: prof?.hasProficiency ? prof.term : null,
      [`${config[type]}Bonus`]: relevant?.bonuses?.check,
      extraBonus: process.bonus,
      [`${abilityId}CheckBonus`]: ability?.bonuses?.check,
      [`${type}Bonus`]: this.system.bonuses?.abilities?.[type],
      abilityCheckBonus: this.system.bonuses?.abilities?.check
    }, { ...rollData });

    // Add exhaustion reduction
    this.addRollExhaustion(parts, data);

    config.parts = [...(config.parts ?? []), ...parts];
    config.data = { ...data, ...(config.data ?? {}) };
    config.data.abilityId = abilityId;
  }

  /* -------------------------------------------- */

  /**
   * Roll a generic ability test or saving throw.
   * Prompt the user for input on which variety of roll they want to do.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   */
  rollAbility(config={}, dialog={}, message={}) {
    let abilityId = config;
    if ( foundry.utils.getType(config) === "Object" ) abilityId = config.ability;
    else foundry.utils.logCompatibilityWarning(
      "The `rollAbility` method on Actor5e now takes roll, dialog, and message config objects as parameters.",
      { since: "DnD5e 4.1", until: "DnD5e 4.5" }
    );
    const label = CONFIG.DND5E.abilities[abilityId]?.label ?? "";
    new Dialog({
      title: `${game.i18n.format("DND5E.AbilityPromptTitle", { ability: label })}: ${this.name}`,
      content: `<p>${game.i18n.format("DND5E.AbilityPromptText", { ability: label })}</p>`,
      buttons: {
        test: {
          label: game.i18n.localize("DND5E.ActionAbil"),
          callback: () => this.rollAbilityCheck(config, dialog, message)
        },
        save: {
          label: game.i18n.localize("DND5E.ActionSave"),
          callback: () => this.rollSavingThrow(config, dialog, message)
        }
      }
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Roll an Ability Check.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instance.
   */
  async rollAbilityCheck(config={}, dialog={}, message={}) {
    const abilityLabel = CONFIG.DND5E.abilities[config.ability]?.label ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.AbilityPromptTitle", { ability: abilityLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollD20Test("check", config, dialogConfig, message);
  }

  /**
   * Roll an Ability Test.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instance.
   */
  async rollAbilityTest(config={}, dialog={}, message={}) {
    foundry.utils.logCompatibilityWarning(
      "The `rollAbilityTest` method on Actor5e has been renamed `rollAbilityCheck`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.5" }
    );
    return this.rollAbilityCheck(config, dialog, message);
  }

  /* -------------------------------------------- */

  /**
   * Roll a Saving Throw.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instances.
   */
  async rollSavingThrow(config={}, dialog={}, message={}) {
    const abilityLabel = CONFIG.DND5E.abilities[config.ability]?.label ?? "";
    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.SavePromptTitle", { ability: abilityLabel }),
          subtitle: this.name
        }
      }
    }, dialog);
    return this.#rollD20Test("save", config, dialogConfig, message);
  }

  /**
   * Roll an Ability Saving Throw.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instances.
   */
  async rollAbilitySave(config={}, dialog={}, message={}) {
    foundry.utils.logCompatibilityWarning(
      "The `rollAbilitySave` method on Actor5e has been renamed `rollSavingThrow`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.5" }
    );
    return this.rollSavingThrow(config, dialog, message);
  }

  /* -------------------------------------------- */

  /**
   * @typedef {D20RollProcessConfiguration} AbilityRollProcessConfiguration
   * @property {string} [ability]  ID of the ability to roll as found in `CONFIG.DND5E.abilities`.
   */

  /**
   * Shared rolling functionality between ability checks & saving throws.
   * @param {"check"|"save"} type                     D20 test type.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}               A Promise which resolves to the created Roll instance.
   */
  async #rollD20Test(type, config={}, dialog={}, message={}) {
    let oldFormat = false;
    const name = type === "check" ? "AbilityCheck" : "SavingThrow";
    const oldName = type === "check" ? "AbilityTest" : "AbilitySave";

    // Handle deprecated calling pattern
    if ( config && (foundry.utils.getType(config) !== "Object") ) {
      foundry.utils.logCompatibilityWarning(
        `The \`roll${oldName}\` method on Actor5e now takes roll, dialog, and message config objects as parameters.`,
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      oldFormat = true;
      const oldConfig = dialog;
      config = { ability: config };
      dialog = {};
      _applyDeprecatedD20Configs(config, dialog, message, oldConfig);
    }

    const ability = this.system.abilities?.[config.ability];
    const abilityConfig = CONFIG.DND5E.abilities[config.ability];

    const rollData = this.getRollData();
    let { parts, data } = CONFIG.Dice.BasicRoll.constructParts({
      mod: ability?.mod,
      prof: ability?.[`${type}Prof`].hasProficiency ? ability[`${type}Prof`].term : null,
      [`${config.ability}${type.capitalize()}Bonus`]: ability?.bonuses[type],
      [`${type}Bonus`]: this.system.bonuses?.abilities?.[type],
      cover: (config.ability === "dex") && (type === "save") ? this.system.attributes?.ac?.cover : null
    }, rollData);
    const options = {};

    const rollConfig = foundry.utils.mergeObject({
      halflingLucky: this.getFlag("dnd5e", "halflingLucky")
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), name, "d20Test"];
    rollConfig.rolls = [
      BasicRoll.mergeConfigs({ parts, data, options }, config.rolls?.shift())
    ].concat(config.rolls ?? []);
    rollConfig.rolls.forEach(({ parts, data }) => this.addRollExhaustion(parts, data));
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.deepClone(dialog);

    const messageConfig = foundry.utils.mergeObject({
      create: true,
      data: {
        flags: {
          dnd5e: {
            messageType: "roll",
            roll: {
              ability: config.ability,
              type: type === "check" ? "ability" : "save"
            }
          }
        },
        flavor: game.i18n.format(
          `DND5E.${type === "check" ? "Ability" : "Save"}PromptTitle`, { ability: abilityConfig?.label ?? "" }
        ),
        speaker: ChatMessage.getSpeaker({ actor: this })
      }
    }, message);

    if ( "dnd5e.preRollAbilityTest" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        `The \`dnd5e.preRoll${oldName}\` hook has been deprecated and replaced with \`dnd5e.preRoll${name}V2\`.`,
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      const oldConfig = _createDeprecatedD20Config(rollConfig, dialogConfig, messageConfig);
      if ( Hooks.call(`dnd5e.preRoll${oldName}`, this, oldConfig, config.ability) === false ) return null;
      _applyDeprecatedD20Configs(rollConfig, dialogConfig, messageConfig, oldConfig);
    }

    const rolls = await CONFIG.Dice.D20Roll.build(rollConfig, dialogConfig, messageConfig);
    if ( !rolls.length ) return null;

    /**
     * A hook event that fires after an ability check or save has been rolled.
     * @function dnd5e.rollAbilityCheck
     * @function dnd5e.rollSavingThrow
     * @memberof hookEvents
     * @param {D20Roll[]} rolls       The resulting rolls.
     * @param {object} data
     * @param {string} data.ability   ID of the ability that was rolled as defined in `CONFIG.DND5E.abilities`.
     * @param {Actor5e} data.subject  Actor for which the roll has been performed.
     */
    Hooks.callAll(`dnd5e.roll${name}`, rolls, { ability: config.ability, subject: this });

    if ( `dnd5e.roll${name}` in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        `The \`dnd5e.roll${oldName}\` hook has been deprecated and replaced with \`dnd5e.roll${name}\`.`,
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      Hooks.callAll(`dnd5e.roll${oldName}`, this, rolls[0], config.ability);
    }

    return oldFormat ? rolls[0] : rolls;
  }

  /* -------------------------------------------- */

  /**
   * Perform a death saving throw, rolling a d20 plus any global save bonuses.
   * @param {Partial<D20RollProcessConfiguration>} config     Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog    Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message  Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                       A Promise which resolves to the Roll instance.
   */
  async rollDeathSave(config={}, dialog={}, message={}) {
    let oldFormat = false;
    const death = this.system.attributes?.death;
    if ( !death ) throw new Error(`Actors of the type '${this.type}' don't support death saves.`);

    // Handle deprecated config object
    if ( config.legacy !== false ) {
      foundry.utils.logCompatibilityWarning(
        "The `rollDeathSave` method on Actor5e now takes roll, dialog, and message config objects as parameters. "
        + "Pass the `legacy: false` option to config object to suppress this warning once updates have been made.",
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      oldFormat = true;
      const oldConfig = config;
      config = {};
      _applyDeprecatedD20Configs(config, dialog, message, oldConfig);
    }

    // Display a warning if we are not at zero HP or if we already have reached 3
    if ( (this.system.attributes.hp.value > 0) || (death.failure >= 3) || (death.success >= 3) ) {
      ui.notifications.warn("DND5E.DeathSaveUnnecessary", { localize: true });
      return null;
    }

    const parts = [];
    let data = {};
    const options = {
      advantage: death.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE,
      disadvantage: death.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE,
      maximum: death.roll.max,
      minimum: death.roll.min
    };

    // Diamond Soul adds proficiency
    if ( this.getFlag("dnd5e", "diamondSoul") ) {
      parts.push("@prof");
      data.prof = new Proficiency(this.system.attributes.prof, 1).term;
    }

    // Death save bonus
    if ( death.bonuses.save ) parts.push(death.bonuses.save);

    const rollConfig = foundry.utils.mergeObject({ target: 10 }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "deathSave"];
    rollConfig.rolls = [
      BasicRoll.mergeConfigs({ parts, data, options }, config.rolls?.shift())
    ].concat(config.rolls ?? []);

    const dialogConfig = foundry.utils.deepClone(dialog);

    const messageConfig = foundry.utils.mergeObject({
      data: {
        flags: {
          dnd5e: {
            roll: {
              type: "death"
            }
          }
        },
        flavor: game.i18n.localize("DND5E.DeathSavingThrow")
      }
    }, message);

    if ( "dnd5e.preRollDeathSave" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preRollDeathSave` hook has been deprecated and replaced with `dnd5e.preRollDeathSaveV2`.",
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      const oldConfig = _createDeprecatedD20Config(rollConfig, dialogConfig, messageConfig);
      if ( Hooks.call("dnd5e.preRollDeathSave", this, oldConfig) === false ) return null;
      _applyDeprecatedD20Configs(rollConfig, dialogConfig, messageConfig, oldConfig);
    }

    const rolls = await this.rollSavingThrow(rollConfig, dialogConfig, messageConfig);
    if ( !rolls?.length ) return null;

    // Take action depending on the result
    const details = {};
    const roll = rolls[0];
    const returnValue = oldFormat ? roll : rolls;

    // Save success
    if ( roll.total >= (roll.options.target ?? 10) ) {
      let successes = (death.success || 0) + 1;

      // Critical Success = revive with 1hp
      if ( roll.isCritical ) {
        details.updates = {
          "system.attributes.death.success": 0,
          "system.attributes.death.failure": 0,
          "system.attributes.hp.value": 1
        };
        details.chatString = "DND5E.DeathSaveCriticalSuccess";
      }

      // 3 Successes = survive and reset checks
      else if ( successes === 3 ) {
        details.updates = {
          "system.attributes.death.success": 0,
          "system.attributes.death.failure": 0
        };
        details.chatString = "DND5E.DeathSaveSuccess";
      }

      // Increment successes
      else details.updates = {"system.attributes.death.success": Math.clamp(successes, 0, 3)};
    }

    // Save failure
    else {
      let failures = (death.failure || 0) + (roll.isFumble ? 2 : 1);
      details.updates = {"system.attributes.death.failure": Math.clamp(failures, 0, 3)};
      if ( failures >= 3 ) {  // 3 Failures = death
        details.chatString = "DND5E.DeathSaveFailure";
      }
    }

    /**
     * A hook event that fires after a death saving throw has been rolled for an Actor, but before
     * updates have been performed.
     * @function dnd5e.rollDeathSave
     * @memberof hookEvents
     * @param {D20Roll[]} rolls         The resulting rolls.
     * @param {object} data
     * @param {string} data.chatString  Localizable string displayed in the create chat message. If not set, then
     *                                  no chat message will be displayed.
     * @param {object} data.updates     Updates that will be applied to the actor as a result of this save.
     * @param {Actor5e} data.subject    Actor for which the death saving throw has been rolled.
     * @returns {boolean}               Explicitly return `false` to prevent updates from being performed.
     */
    if ( Hooks.call("dnd5e.rollDeathSaveV2", rolls, { ...details, subject: this }) === false ) return returnValue;

    if ( "dnd5e.rollDeathSave" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.rollDeathSave` hook has been deprecated and replaced with `dnd5e.rollDeathSaveV2`.",
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      if ( Hooks.call("dnd5e.rollDeathSave", this, rolls[0], details) === false ) return returnValue;
    }

    if ( !foundry.utils.isEmpty(details.updates) ) await this.update(details.updates);

    // Display success/failure chat message
    let resultsMessage;
    if ( details.chatString ) {
      const chatData = {
        content: game.i18n.format(details.chatString, { name: this.name }),
        speaker: messageConfig.speaker ?? ChatMessage.getSpeaker({ actor: this })
      };
      ChatMessage.applyRollMode(chatData, roll.options.rollMode);
      resultsMessage = await ChatMessage.create(chatData);
    }

    /**
     * A hook event that fires after a death saving throw has been rolled and after changes have been applied.
     * @function dnd5e.postRollDeathSave
     * @memberof hookEvents
     * @param {D20Roll[]} rolls                  The resulting rolls.
     * @param {object} data
     * @param {ChatMessage5e|void} data.message  The created results chat message.
     * @param {Actor5e} data.subject             Actor for which the death saving throw has been rolled.
     */
    Hooks.callAll("dnd5e.postRollDeathSave", rolls, { message: resultsMessage, subject: this });

    return returnValue;
  }

  /* -------------------------------------------- */

  /**
   * Perform a saving throw to maintain concentration.
   * @param {Partial<AbilityRollProcessConfiguration>} config  Configuration information for the roll.
   * @param {Partial<BasicRollDialogConfiguration>} dialog     Configuration for the roll dialog.
   * @param {Partial<BasicRollMessageConfiguration>} message   Configuration for the roll message.
   * @returns {Promise<D20Roll[]|null>}                        A Promise which resolves to the created Roll instance.
   */
  async rollConcentration(config={}, dialog={}, message={}) {
    let oldFormat = false;
    if ( !this.isOwner ) return null;
    const conc = this.system.attributes?.concentration;
    if ( !conc ) throw new Error("You may not make a Concentration Saving Throw with this Actor.");

    // Handle deprecated config object
    if ( config.legacy !== false ) {
      foundry.utils.logCompatibilityWarning(
        "The `rollConcentration` method on Actor5e now takes roll, dialog, and message config objects as parameters."
        + "Pass the `legacy: false` option to config object to suppress this warning once updates have been made.",
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      oldFormat = true;
      const oldConfig = config;
      config = {};
      _applyDeprecatedD20Configs(config, dialog, message, oldConfig);
    }

    let data = {};
    const parts = [];
    const options = {
      advantage: conc.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE,
      disadvantage: conc.roll.mode === CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE,
      maximum: conc.roll.max,
      minimum: conc.roll.min
    };

    // Concentration bonus
    if ( conc.bonuses.save ) parts.push(conc.bonuses.save);

    const rollConfig = foundry.utils.mergeObject({
      ability: (conc.ability in CONFIG.DND5E.abilities) ? conc.ability : CONFIG.DND5E.defaultAbilities.concentration,
      isConcentration: true,
      target: 10
    }, config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "concentration"];
    rollConfig.rolls = [
      BasicRoll.mergeConfigs({ parts, data, options }, config.rolls?.shift())
    ].concat(config.rolls ?? []);

    const dialogConfig = foundry.utils.mergeObject({
      options: {
        window: {
          title: game.i18n.format("DND5E.SavePromptTitle", { ability: game.i18n.localize("DND5E.Concentration") })
        }
      }
    }, dialog);

    const messageConfig = foundry.utils.deepClone(message);

    /**
     * A hook event that fires before a saving throw to maintain concentration is rolled for an Actor.
     * @function dnd5e.preRollConcentration
     * @memberof hookEvents
     * @param {Actor5e} actor                   Actor for which the saving throw is being rolled.
     * @param {D20RollConfiguration} options    Configuration data for the pending roll.
     * @returns {boolean}                       Explicitly return `false` to prevent the save from being performed.
     */
    if ( "dnd5e.preRollConcentration" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preRollConcentration` hook has been deprecated and replaced with `dnd5e.preRollConcentrationV2`.",
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      const oldConfig = _createDeprecatedD20Config(rollConfig, dialogConfig, messageConfig);
      if ( Hooks.call("dnd5e.preRollConcentration", this, oldConfig) === false ) return null;
      _applyDeprecatedD20Configs(rollConfig, dialogConfig, messageConfig, oldConfig);
    }

    const rolls = await this.rollSavingThrow(rollConfig, dialogConfig, messageConfig);
    if ( !rolls?.length ) return null;

    /**
     * A hook event that fires after a saving throw to maintain concentration is rolled for an Actor.
     * @function dnd5e.rollConcentration
     * @memberof hookEvents
     * @param {D20Roll[]} rolls     The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.actor  Actor for which the saving throw has been rolled.
     */
    Hooks.callAll("dnd5e.rollConcentrationV2", rolls, { subject: this });

    if ( "dnd5e.rollConcentration" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.rollConcentration` hook has been deprecated and replaced with `dnd5e.rollConcentrationV2`.",
        { since: "DnD5e 4.1", until: "DnD5e 4.5" }
      );
      Hooks.callAll("dnd5e.rollConcentration", this, rolls[0]);
    }

    return oldFormat ? rolls[0] : rolls;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {D20RollOptions} InitiativeRollOptions
   * @property {D20Roll.ADV_MODE} [advantageMode]  A specific advantage mode to apply.
   * @property {number} [fixed]                    Fixed initiative value to use rather than rolling.
   * @property {string} [flavor]                   Special flavor text to apply to the created message.
   */

  /**
   * Get an un-evaluated D20Roll instance used to roll initiative for this Actor.
   * @param {Partial<InitiativeRollOptions>} options  Configuration information for the roll.
   * @returns {D20Roll|null}                          The constructed but unevaluated D20Roll.
   */
  getInitiativeRoll(options={}) {
    // Use a temporarily cached initiative roll
    if ( this._cachedInitiativeRoll ) return this._cachedInitiativeRoll.clone();
    const config = this.getInitiativeRollConfig(options);
    if ( !config ) return null;

    // Create a normal D20 roll
    if ( config.options?.fixed === undefined ) {
      const formula = ["1d20"].concat(config.parts).join(" + ");
      return new CONFIG.Dice.D20Roll(formula, config.data, config.options);
    }

    // Create a basic roll with the fixed score
    return new CONFIG.Dice.BasicRoll(String(config.options.fixed), config.data, config.options);
  }

  /* -------------------------------------------- */

  /**
   * Get an un-evaluated D20Roll instance used to roll initiative for this Actor.
   * @param {Partial<InitiativeRollOptions>} options  Configuration information for the roll.
   * @returns {D20RollConfiguration|null}             Roll configuration.
   */
  getInitiativeRollConfig(options={}) {
    const init = this.system.attributes?.init;
    const flags = this.flags.dnd5e ?? {};
    const abilityId = init?.ability || CONFIG.DND5E.defaultAbilities.initiative;
    const ability = this.system.abilities?.[abilityId];

    const rollData = this.getRollData();
    let { parts, data } = CONFIG.Dice.BasicRoll.constructParts({
      mod: init?.mod,
      prof: init.prof.hasProficiency ? init.prof.term : null,
      initiativeBonus: init.bonus,
      [`${abilityId}AbilityCheckBonus`]: ability?.bonuses?.check,
      abilityCheckBonus: this.system.bonuses?.abilities?.check,
      alert: flags.initiativeAlert && (game.settings.get("dnd5e", "rulesVersion") === "legacy") ? 5 : null
    }, rollData);

    const remarkableAthlete = flags.remarkableAthlete && (game.settings.get("dnd5e", "rulesVersion") === "modern");
    if ( flags.initiativeAdv || remarkableAthlete ) options.advantage ??= true;

    // Add exhaustion reduction
    this.addRollExhaustion(parts, data);

    // Ability score tiebreaker
    const tiebreaker = game.settings.get("dnd5e", "initiativeDexTiebreaker");
    if ( tiebreaker && Number.isNumeric(ability?.value) ) parts.push(String(ability.value / 100));

    // Fixed initiative score
    const scoreMode = game.settings.get("dnd5e", "initiativeScore");
    const useScore = (scoreMode === "all") || ((scoreMode === "npcs") && game.user.isGM && (this.type === "npc"));

    options = foundry.utils.mergeObject({
      fixed: useScore ? init.score : undefined,
      flavor: options.flavor ?? game.i18n.localize("DND5E.Initiative"),
      halflingLucky: flags.halflingLucky ?? false,
      maximum: init.roll.max,
      minimum: init.roll.min
    }, options);

    const rollConfig = { parts, data, options, subject: this };

    /**
     * A hook event that fires before initiative roll is prepared for an Actor.
     * @function dnd5e.preConfigureInitiative
     * @memberof hookEvents
     * @param {Actor5e} subject              The Actor that is rolling initiative.
     * @param {D20RollConfiguration} config  Configuration data for the pending roll.
     */
    Hooks.callAll("dnd5e.preConfigureInitiative", this, rollConfig);

    return rollConfig;
  }

  /* -------------------------------------------- */

  /**
   * Roll initiative for this Actor with a dialog that provides an opportunity to elect advantage or other bonuses.
   * @param {Partial<InitiativeRollOptions>} [rollOptions={}]  Options forwarded to the Actor#getInitiativeRoll method.
   * @returns {Promise<void>}           A promise which resolves once initiative has been rolled for the Actor.
   */
  async rollInitiativeDialog(rollOptions={}) {
    const config = {
      evaluate: false,
      event: rollOptions.event,
      hookNames: ["initiativeDialog", "abilityCheck", "d20Test"],
      rolls: [this.getInitiativeRollConfig(rollOptions)],
      subject: this
    };
    if ( !config.rolls[0] ) return;

    // Display the roll configuration dialog
    const messageOptions = { rollMode: game.settings.get("core", "rollMode") };
    if ( config.rolls[0].options?.fixed === undefined ) {
      const dialog = { options: { title: game.i18n.localize("DND5E.InitiativeRoll") } };
      const rolls = await CONFIG.Dice.D20Roll.build(config, dialog, messageOptions);
      if ( !rolls.length ) return;
      this._cachedInitiativeRoll = rolls[0];
    }

    // Just create a basic roll with the fixed score
    else {
      const { data, options } = config.rolls[0];
      this._cachedInitiativeRoll = new CONFIG.Dice.BasicRoll(String(options.fixed), data, options);
    }

    await this.rollInitiative({ createCombatants: true, initiativeOptions: { messageOptions } });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async rollInitiative(options={}, rollOptions={}) {
    this._cachedInitiativeRoll ??= this.getInitiativeRoll(rollOptions);

    /**
     * A hook event that fires before initiative is rolled for an Actor.
     * @function dnd5e.preRollInitiative
     * @memberof hookEvents
     * @param {Actor5e} actor  The Actor that is rolling initiative.
     * @param {D20Roll} roll   The initiative roll.
     */
    if ( Hooks.call("dnd5e.preRollInitiative", this, this._cachedInitiativeRoll) === false ) {
      delete this._cachedInitiativeRoll;
      return null;
    }

    const combat = await super.rollInitiative(options);
    const combatants = this.isToken ? this.getActiveTokens(false, true).reduce((arr, t) => {
      const combatant = game.combat.getCombatantByToken(t.id);
      if ( combatant ) arr.push(combatant);
      return arr;
    }, []) : [game.combat.getCombatantByActor(this.id)];

    /**
     * A hook event that fires after an Actor has rolled for initiative.
     * @function dnd5e.rollInitiative
     * @memberof hookEvents
     * @param {Actor5e} actor           The Actor that rolled initiative.
     * @param {Combatant[]} combatants  The associated Combatants in the Combat.
     */
    Hooks.callAll("dnd5e.rollInitiative", this, combatants);
    delete this._cachedInitiativeRoll;
    return combat;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {BasicRollProcessConfiguration} HitDieRollProcessConfiguration
   * @property {string} [denomination]  The denomination of hit die to roll with the leading letter (e.g. `d8`).
   *                                    If no denomination is provided, the first available hit die will be used.
   * @property {boolean} [modifyHitDice=true]    Should the actor's spent hit dice count be updated?
   * @property {boolean} [modifyHitPoints=true]  Should the actor's hit points be updated after the roll?
   */

  /**
   * Roll a hit die of the appropriate type, gaining hit points equal to the die roll plus your CON modifier.
   * @param {HitDieRollProcessConfiguration} config  Configuration information for the roll.
   * @param {BasicRollDialogConfiguration} dialog    Configuration for the roll dialog.
   * @param {BasicRollMessageConfiguration} message  Configuration for the roll message.
   * @returns {Promise<BasicRoll[]|null>}            The created Roll instances, or `null` if no hit die was rolled.
   */
  async rollHitDie(config={}, dialog={}, message={}) {
    let formula;
    let oldFormat = false;

    // Handle deprecated calling pattern
    if ( config && (foundry.utils.getType(config) !== "Object") ) {
      foundry.utils.logCompatibilityWarning(
        "Actor5e.rollHitDie now takes roll, dialog, and message config objects as parameters.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      oldFormat = true;
      formula = dialog.formula;
      config = { denomination: config, data: dialog.data };
      message = { create: dialog.chatMessage, data: dialog.messageData };
      dialog = {};
    }

    let cls = null;

    // NPCs only have one denomination
    if ( this.type === "npc" ) {
      config.denomination = `d${this.system.attributes.hd.denomination}`;

      // If no hit dice are available, display an error notification
      if ( !this.system.attributes.hd.value ) {
        ui.notifications.error(game.i18n.format("DND5E.HitDiceNPCWarn", {name: this.name}));
        return null;
      }
    }

    // Otherwise check classes
    else {
      // If no denomination was provided, choose the first available
      if ( !config.denomination ) {
        cls = this.system.attributes.hd.classes.find(c => c.system.hd.value);
        if ( !cls ) return null;
        config.denomination = cls.system.hd.denomination;
      }

      // Otherwise, locate a class (if any) which has an available hit die of the requested denomination
      else cls = this.system.attributes.hd.classes.find(i => {
        return (i.system.hd.denomination === config.denomination) && i.system.hd.value;
      });

      // If no class is available, display an error notification
      if ( !cls ) {
        ui.notifications.error(game.i18n.format("DND5E.HitDiceWarn", {name: this.name, formula: config.denomination}));
        return null;
      }
    }

    formula ??= `max(0, 1${config.denomination} + @abilities.con.mod)`;
    const rollConfig = foundry.utils.deepClone(config);
    rollConfig.hookNames = [...(config.hookNames ?? []), "hitDie"];
    rollConfig.rolls = [{ parts: [formula], data: this.getRollData() }].concat(config.rolls ?? []);
    rollConfig.subject = this;

    const dialogConfig = foundry.utils.mergeObject({
      configure: false
    }, dialog);

    const flavor = game.i18n.localize("DND5E.HitDiceRoll");
    const messageConfig = foundry.utils.mergeObject({
      rollMode: game.settings.get("core", "rollMode"),
      data: {
        speaker: ChatMessage.implementation.getSpeaker({actor: this}),
        flavor,
        title: `${flavor}: ${this.name}`,
        "flags.dnd5e.roll": {type: "hitDie"}
      }
    }, message);

    if ( "dnd5e.preRollHitDie" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.preRollHitDie` hook has been deprecated and replaced with `dnd5e.preRollHitDieV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      const hookData = {
        formula: rollConfig.rolls[0].parts[0], data: rollConfig.rolls[0].data,
        chatMessage: messageConfig.create, messageData: messageConfig.data
      };
      if ( Hooks.call("dnd5e.preRollHitDie", this, hookData, rollConfig.denomination) === false ) return null;
      rollConfig.rolls[0].parts[0] = hookData.formula;
      rollConfig.rolls[0].data = hookData.data;
      messageConfig.create = hookData.chatMessage;
      messageConfig.data = hookData.messageData;
    }

    const rolls = await CONFIG.Dice.BasicRoll.build(rollConfig, dialogConfig, messageConfig);
    if ( !rolls.length ) return null;
    const returnValue = oldFormat && rolls?.length ? rolls[0] : rolls;

    const updates = { actor: {}, class: {} };
    if ( rollConfig.modifyHitDice !== false ) {
      if ( cls ) updates.class["system.hd.spent"] = cls.system.hd.spent + 1;
      else updates.actor["system.attributes.hd.spent"] = this.system.attributes.hd.spent + 1;
    }
    const hp = this.system.attributes.hp;
    if ( rollConfig.modifyHitPoints !== false ) {
      const dhp = Math.min(Math.max(0, hp.effectiveMax) - hp.value, rolls.reduce((t, r) => t + r.total, 0));
      updates.actor["system.attributes.hp.value"] = hp.value + dhp;
    }

    /**
     * A hook event that fires after a hit die has been rolled for an Actor, but before updates have been performed.
     * @function dnd5e.rollHitDieV2
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls          The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.subject       Actor for which the hit die has been rolled.
     * @param {object} data.updates
     * @param {object} data.updates.actor  Updates that will be applied to the actor.
     * @param {object} data.updates.class  Updates that will be applied to the class.
     * @returns {boolean}                  Explicitly return `false` to prevent updates from being performed.
     */
    if ( Hooks.call("dnd5e.rollHitDieV2", rolls, { subject: this, updates }) === false ) return returnValue;

    if ( "dnd5e.rollHitDie" in Hooks.events ) {
      foundry.utils.logCompatibilityWarning(
        "The `dnd5e.rollHitDie` hook has been deprecated and replaced with `dnd5e.rollHitDieV2`.",
        { since: "DnD5e 4.0", until: "DnD5e 4.4" }
      );
      if ( Hooks.call("dnd5e.rollHitDie", this, rolls[0], updates) === false ) return null;
    }

    // Perform updates
    if ( !foundry.utils.isEmpty(updates.actor) ) await this.update(updates.actor);
    if ( !foundry.utils.isEmpty(updates.class) ) await cls.update(updates.class);

    /**
     * A hook event that fires after a hit die has been rolled for an Actor and updates have been performed.
     * @function dnd5e.postRollHitDie
     * @memberof hookEvents
     * @param {BasicRoll[]} rolls     The resulting rolls.
     * @param {object} data
     * @param {Actor5e} data.subject  Actor for which the roll was performed.
     */
    Hooks.callAll("dnd5e.postRollHitDie", rolls, { subject: this });

    return returnValue;
  }

  /* -------------------------------------------- */

  /**
   * Roll hit points for a specific class as part of a level-up workflow.
   * @param {Item5e} item                         The class item whose hit dice to roll.
   * @param {object} options
   * @param {boolean} [options.chatMessage=true]  Display the chat message for this roll.
   * @returns {Promise<Roll>}                     The completed roll.
   * @see {@link dnd5e.preRollClassHitPoints}
   */
  async rollClassHitPoints(item, { chatMessage=true }={}) {
    if ( item.type !== "class" ) throw new Error("Hit points can only be rolled for a class item.");
    const rollData = {
      formula: `1${item.system.hd.denomination}`,
      data: item.getRollData(),
      chatMessage
    };
    const flavor = game.i18n.format("DND5E.ADVANCEMENT.HitPoints.Roll", { class: item.name });
    const messageData = {
      title: `${flavor}: ${this.name}`,
      flavor,
      speaker: ChatMessage.implementation.getSpeaker({ actor: this }),
      "flags.dnd5e.roll": { type: "hitPoints" }
    };

    /**
     * A hook event that fires before hit points are rolled for a character's class.
     * @function dnd5e.preRollClassHitPoints
     * @memberof hookEvents
     * @param {Actor5e} actor            Actor for which the hit points are being rolled.
     * @param {Item5e} item              The class item whose hit dice will be rolled.
     * @param {object} rollData
     * @param {string} rollData.formula  The string formula to parse.
     * @param {object} rollData.data     The data object against which to parse attributes within the formula.
     * @param {object} messageData       The data object to use when creating the message.
     */
    Hooks.callAll("dnd5e.preRollClassHitPoints", this, item, rollData, messageData);

    const roll = new Roll(rollData.formula, rollData.data);
    await roll.evaluate();

    /**
     * A hook event that fires after hit points haven been rolled for a character's class.
     * @function dnd5e.rollClassHitPoints
     * @memberof hookEvents
     * @param {Actor5e} actor  Actor for which the hit points have been rolled.
     * @param {Roll} roll      The resulting roll.
     */
    Hooks.callAll("dnd5e.rollClassHitPoints", this, roll);

    if ( rollData.chatMessage ) await roll.toMessage(messageData);
    return roll;
  }

  /* -------------------------------------------- */

  /**
   * Roll hit points for an NPC based on the HP formula.
   * @param {object} options
   * @param {boolean} [options.chatMessage=true]  Display the chat message for this roll.
   * @returns {Promise<Roll>}                     The completed roll.
   * @see {@link dnd5e.preRollNPCHitPoints}
   */
  async rollNPCHitPoints({ chatMessage=true }={}) {
    if ( this.type !== "npc" ) throw new Error("NPC hit points can only be rolled for NPCs");
    const rollData = {
      formula: this.system.attributes.hp.formula,
      data: this.getRollData(),
      chatMessage
    };
    const flavor = game.i18n.format("DND5E.HPFormulaRollMessage");
    const messageData = {
      title: `${flavor}: ${this.name}`,
      flavor,
      speaker: ChatMessage.getSpeaker({ actor: this }),
      "flags.dnd5e.roll": { type: "hitPoints" }
    };

    /**
     * A hook event that fires before hit points are rolled for an NPC.
     * @function dnd5e.preRollNPCHitPoints
     * @memberof hookEvents
     * @param {Actor5e} actor            Actor for which the hit points are being rolled.
     * @param {object} rollData
     * @param {string} rollData.formula  The string formula to parse.
     * @param {object} rollData.data     The data object against which to parse attributes within the formula.
     * @param {object} messageData       The data object to use when creating the message.
     */
    Hooks.callAll("dnd5e.preRollNPCHitPoints", this, rollData, messageData);

    const roll = new Roll(rollData.formula, rollData.data);
    await roll.evaluate();

    /**
     * A hook event that fires after hit points are rolled for an NPC.
     * @function dnd5e.rollNPCHitPoints
     * @memberof hookEvents
     * @param {Actor5e} actor  Actor for which the hit points have been rolled.
     * @param {Roll} roll      The resulting roll.
     */
    Hooks.callAll("dnd5e.rollNPCHitPoints", this, roll);

    if ( rollData.chatMessage ) await roll.toMessage(messageData);
    return roll;
  }

  /* -------------------------------------------- */
  /*  Resting                                     */
  /* -------------------------------------------- */

  /**
   * Configuration options for a rest.
   *
   * @typedef {object} RestConfiguration
   * @property {string} type                   Type of rest to perform.
   * @property {boolean} dialog                Present a dialog window which allows for rolling hit dice as part of the
   *                                           Short Rest and selecting whether a new day has occurred.
   * @property {boolean} chat                  Should a chat message be created to summarize the results of the rest?
   * @property {number} duration               Amount of time passed during the rest in minutes.
   * @property {boolean} newDay                Does this rest carry over to a new day?
   * @property {boolean} [advanceBastionTurn]  Should a bastion turn be advanced for all players?
   * @property {boolean} [advanceTime]         Should the game clock be advanced by the rest duration?
   * @property {boolean} [autoHD]              Should hit dice be spent automatically during a short rest?
   * @property {number} [autoHDThreshold]      How many hit points should be missing before hit dice are
   *                                           automatically spent during a short rest.
   */

  /**
   * Results from a rest operation.
   *
   * @typedef {object} RestResult
   * @property {string} type              Type of rest performed.
   * @property {Actor5e} clone            Clone of the actor before rest is performed.
   * @property {object} deltas
   * @property {number} deltas.hitPoints  Hit points recovered during the rest.
   * @property {number} deltas.hitDice    Hit dice recovered or spent during the rest.
   * @property {boolean} newDay           Whether a new day occurred during the rest.
   * @property {Roll[]} rolls             Any rolls that occurred during the rest process, not including hit dice.
   * @property {object} updateData        Updates applied to the actor.
   * @property {object[]} updateItems     Updates applied to actor's items.
   */

  /* -------------------------------------------- */

  /**
   * Take a short rest, possibly spending hit dice and recovering resources, item uses, and relevant spell slots.
   * @param {RestConfiguration} [config]  Configuration options for a short rest.
   * @returns {Promise<RestResult>}       A Promise which resolves once the short rest workflow has completed.
   */
  async shortRest(config={}) {
    if ( this.type === "vehicle" ) return;
    const clone = this.clone();

    config = foundry.utils.mergeObject({
      type: "short", dialog: true, chat: true, newDay: false, advanceTime: false, autoHD: false, autoHDThreshold: 3,
      duration: CONFIG.DND5E.restTypes.short.duration[game.settings.get("dnd5e", "restVariant")]
    }, config);

    /**
     * A hook event that fires before a short rest is started.
     * @function dnd5e.preShortRest
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor that is being rested.
     * @param {RestConfiguration} config  Configuration options for the rest.
     * @returns {boolean}                 Explicitly return `false` to prevent the rest from being started.
     */
    if ( Hooks.call("dnd5e.preShortRest", this, config) === false ) return;

    // Take note of the initial hit points and number of hit dice the Actor has
    const hd0 = foundry.utils.getProperty(this, "system.attributes.hd.value");
    const hp0 = foundry.utils.getProperty(this, "system.attributes.hp.value");

    // Display a Dialog for rolling hit dice
    if ( config.dialog ) {
      try {
        foundry.utils.mergeObject(config, await ShortRestDialog.configure(this, config));
      } catch(err) { return; }
    }

    /**
     * A hook event that fires after a short rest has started, after the configuration is complete.
     * @function dnd5e.shortRest
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor that is being rested.
     * @param {RestConfiguration} config  Configuration options for the rest.
     * @returns {boolean}                 Explicitly return `false` to prevent the rest from being continued.
     */
    if ( Hooks.call("dnd5e.shortRest", this, config) === false ) return;

    // Automatically spend hit dice
    if ( config.autoHD ) await this.autoSpendHitDice({ threshold: config.autoHDThreshold });

    // Return the rest result
    const dhd = foundry.utils.getProperty(this, "system.attributes.hd.value") - hd0;
    const dhp = foundry.utils.getProperty(this, "system.attributes.hp.value") - hp0;
    return this._rest(config, { clone, dhd, dhp });
  }

  /* -------------------------------------------- */

  /**
   * Take a long rest, recovering hit points, hit dice, resources, item uses, and spell slots.
   * @param {RestConfiguration} [config]  Configuration options for a long rest.
   * @returns {Promise<RestResult>}       A Promise which resolves once the long rest workflow has completed.
   */
  async longRest(config={}) {
    if ( this.type === "vehicle" ) return;
    const clone = this.clone();

    config = foundry.utils.mergeObject({
      type: "long", dialog: true, chat: true, newDay: true, advanceTime: false,
      duration: CONFIG.DND5E.restTypes.long.duration[game.settings.get("dnd5e", "restVariant")]
    }, config);

    /**
     * A hook event that fires before a long rest is started.
     * @function dnd5e.preLongRest
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor that is being rested.
     * @param {RestConfiguration} config  Configuration options for the rest.
     * @returns {boolean}                 Explicitly return `false` to prevent the rest from being started.
     */
    if ( Hooks.call("dnd5e.preLongRest", this, config) === false ) return;

    if ( config.dialog ) {
      try {
        foundry.utils.mergeObject(config, await LongRestDialog.configure(this, config));
      } catch(err) { return; }
    }

    /**
     * A hook event that fires after a long rest has started, after the configuration is complete.
     * @function dnd5e.longRest
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor that is being rested.
     * @param {RestConfiguration} config  Configuration options for the rest.
     * @returns {boolean}                 Explicitly return `false` to prevent the rest from being continued.
     */
    if ( Hooks.call("dnd5e.longRest", this, config) === false ) return;

    return this._rest(config, { clone });
  }

  /* -------------------------------------------- */

  /**
   * Perform all of the changes needed for a short or long rest.
   *
   * @param {RestConfiguration} config         Configuration data for the rest occurring.
   * @param {Partial<RestResult>} [result={}]  Results of the rest operation being built.
   * @returns {Promise<RestResult>}  Consolidated results of the rest workflow.
   * @private
   */
  async _rest(config, result={}) {
    if ( (foundry.utils.getType(this.system.rest) === "function")
      && (await this.system.rest(config, result) === false) ) return;

    result = foundry.utils.mergeObject({
      type: config.type,
      deltas: {
        hitPoints: 0,
        hitDice: 0
      },
      updateData: {},
      updateItems: [],
      newDay: config.newDay === true,
      rolls: []
    }, result);
    result.clone ??= this.clone();
    if ( "dhp" in result ) result.deltas.hitPoints = result.dhp;
    if ( "dhd" in result ) result.deltas.hitDice = result.dhd;

    this._getRestHitDiceRecovery(config, result);
    this._getRestHitPointRecovery(config, result);
    this._getRestResourceRecovery(config, result);
    this._getRestSpellRecovery(config, result);
    await this._getRestItemUsesRecovery(config, result);

    result.dhp = result.deltas.hitPoints;
    result.dhd = result.deltas.hitDice;
    result.longRest = result.type === "long";

    /**
     * A hook event that fires after rest result is calculated, but before any updates are performed.
     * @function dnd5e.preRestCompleted
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor that is being rested.
     * @param {RestResult} result         Details on the rest to be completed.
     * @param {RestConfiguration} config  Configuration data for the rest occurring.
     * @returns {boolean}                 Explicitly return `false` to prevent the rest updates from being performed.
     */
    if ( Hooks.call("dnd5e.preRestCompleted", this, result, config) === false ) return result;

    // Perform updates
    await this.update(result.updateData, { isRest: true });
    await this.updateEmbeddedDocuments("Item", result.updateItems, { isRest: true });

    // Advance the game clock
    if ( config.advanceTime && (config.duration > 0) && game.user.isGM ) await game.time.advance(60 * config.duration);

    // Display a Chat Message summarizing the rest effects
    if ( config.chat ) await this._displayRestResultMessage(config, result);

    /**
     * A hook event that fires when the rest process is completed for an actor.
     * @function dnd5e.restCompleted
     * @memberof hookEvents
     * @param {Actor5e} actor             The actor that just completed resting.
     * @param {RestResult} result         Details on the rest completed.
     * @param {RestConfiguration} config  Configuration data for that occurred.
     */
    Hooks.callAll("dnd5e.restCompleted", this, result, config);

    if ( config.advanceBastionTurn && game.user.isGM && game.settings.get("dnd5e", "bastionConfiguration").enabled
      && this.itemTypes.facility.length ) await dnd5e.bastion.advanceAllFacilities(this);

    // Return data summarizing the rest effects
    return result;
  }

  /* -------------------------------------------- */

  /**
   * Display a chat message with the result of a rest.
   *
   * @param {RestConfiguration} config  Rest configuration.
   * @param {RestResult} result         Result of the rest operation.
   * @returns {Promise<ChatMessage>}    Chat message that was created.
   * @protected
   */
  async _displayRestResultMessage(config, result) {
    let { dhd, dhp, newDay } = result;
    if ( config.type === "short" ) dhd *= -1;
    const diceRestored = dhd !== 0;
    const healthRestored = dhp !== 0;
    const longRest = config.type === "long";
    const length = longRest ? "Long" : "Short";

    const typeConfig = CONFIG.DND5E.restTypes[config.type] ?? {};
    const duration = convertTime(config.duration, "minute");
    const parts = [formatTime(duration.value, duration.unit)];
    if ( newDay ) parts.push(game.i18n.localize("DND5E.REST.NewDay.Label").toLowerCase());
    const restFlavor = `${typeConfig.label} (${game.i18n.getListFormatter({ type: "unit" }).format(parts)})`;

    // Determine the chat message to display
    let message;
    if ( diceRestored && healthRestored ) message = `DND5E.REST.${length}.Result.Full`;
    else if ( longRest && !diceRestored && healthRestored ) message = "DND5E.REST.Long.Result.HitPoints";
    else if ( longRest && diceRestored && !healthRestored ) message = "DND5E.REST.Long.Result.HitDice";
    else message = `DND5E.REST.${length}.Result.Short`;

    // Create a chat message
    const pr = new Intl.PluralRules(game.i18n.lang);
    let chatData = {
      content: game.i18n.format(message, {
        name: this.name,
        dice: game.i18n.format(`DND5E.HITDICE.Counted.${pr.select(dhd)}`, { number: formatNumber(dhd) }),
        health: game.i18n.format(`DND5E.HITPOINTS.Counted.${pr.select(dhp)}`, { number: formatNumber(dhp) })
      }),
      flavor: game.i18n.localize(restFlavor),
      type: "rest",
      rolls: result.rolls,
      speaker: ChatMessage.getSpeaker({ actor: this, alias: this.name }),
      system: {
        activations: ActivationsField.getActivations(this, typeConfig?.activationPeriods ?? []),
        deltas: ActorDeltasField.getDeltas(result.clone, { actor: result.updateData, item: result.updateItems }),
        type: result.type
      }
    };
    ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode"));
    return ChatMessage.create(chatData);
  }

  /* -------------------------------------------- */

  /**
   * Automatically spend hit dice to recover hit points up to a certain threshold.
   * @param {object} [options]
   * @param {number} [options.threshold=3]  A number of missing hit points which would trigger an automatic HD roll.
   * @returns {Promise<number>}             Number of hit dice spent.
   */
  async autoSpendHitDice({ threshold=3 }={}) {
    const hp = this.system.attributes.hp;
    const max = Math.max(0, hp.effectiveMax);
    let diceRolled = 0;
    while ( (this.system.attributes.hp.value + threshold) <= max ) {
      const r = await this.rollHitDie();
      if ( r === null ) break;
      diceRolled += 1;
    }
    return diceRolled;
  }

  /* -------------------------------------------- */

  /**
   * Recovers class hit dice during a long rest.
   *
   * @param {RestConfiguration} [config]
   * @param {number} [config.maxHitDice]  Maximum number of hit dice to recover.
   * @param {number} [config.fraction]    Fraction of max hit dice to recover. Used for NPC recovery and for PCs if
   *                                      `maxHitDice` isn't specified.
   * @param {RestResult} [result={}]      Rest result being constructed.
   * @protected
   */
  _getRestHitDiceRecovery({ maxHitDice, fraction, ...config }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type];
    if ( !this.system.attributes.hd || !restConfig?.recoverHitDice ) return;
    fraction ??= game.settings.get("dnd5e", "rulesVersion") === "modern" ? 1 : 0.5;

    // Handle simpler HD recovery for NPCs
    if ( this.type === "npc" ) {
      const hd = this.system.attributes.hd;
      const recovered = Math.min(
        Math.max(1, Math.floor(hd.max * fraction)), hd.spent, maxHitDice ?? Infinity
      );
      foundry.utils.mergeObject(result, {
        deltas: {
          hitDice: (result.deltas?.hitDice ?? 0) + recovered
        },
        updateData: {
          "system.attributes.hd.spent": hd.spent - recovered
        }
      });
      return;
    }

    this.system.attributes.hd.createHitDiceUpdates({ maxHitDice, fraction, ...config }, result);
  }

  /* -------------------------------------------- */

  /**
   * Recovers actor hit points and eliminates any temp HP.
   * @param {RestConfiguration} [config={}]
   * @param {boolean} [config.recoverTemp=true]     Reset temp HP to zero.
   * @param {boolean} [config.recoverTempMax=true]  Reset temp max HP to zero.
   * @param {RestResult} [result={}]                Rest result being constructed.
   * @protected
   */
  _getRestHitPointRecovery({ recoverTemp, recoverTempMax, ...config }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type ?? "long"];
    const hp = this.system.attributes?.hp;
    if ( !hp || !restConfig.recoverHitPoints ) return;

    let max = hp.max;
    result.updateData ??= {};
    if ( recoverTempMax ) result.updateData["system.attributes.hp.tempmax"] = 0;
    else max = Math.max(0, hp.effectiveMax);
    result.updateData["system.attributes.hp.value"] = max;
    if ( recoverTemp ) result.updateData["system.attributes.hp.temp"] = 0;
    foundry.utils.setProperty(
      result, "deltas.hitPoints", (result.deltas?.hitPoints ?? 0) + Math.max(0, max - hp.value)
    );
  }

  /* -------------------------------------------- */

  /**
   * Recovers actor resources.
   * @param {object} [config={}]
   * @param {boolean} [config.recoverShortRestResources]  Recover resources that recharge on a short rest.
   * @param {boolean} [config.recoverLongRestResources]   Recover resources that recharge on a long rest.
   * @param {RestResult} [result={}]                      Rest result being constructed.
   * @protected
   */
  _getRestResourceRecovery({recoverShortRestResources, recoverLongRestResources, ...config}={}, result={}) {
    recoverShortRestResources ??= config.type === "short";
    recoverLongRestResources ??= config.type === "long";
    for ( let [k, r] of Object.entries(this.system.resources ?? {}) ) {
      if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) {
        result.updateData[`system.resources.${k}.value`] = Number(r.max);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Recovers expended spell slots.
   * @param {RestConfiguration} [config={}]
   * @param {boolean} [config.recoverShort]    Recover slots that return on short rests.
   * @param {boolean} [config.recoverLong]     Recover slots that return on long rests.
   * @param {RestResult} [result={}]           Rest result being constructed.
   * @protected
   */
  _getRestSpellRecovery({ recoverShort, recoverLong, ...config }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type];
    if ( !this.system.spells ) return;

    let types = restConfig.recoverSpellSlotTypes;
    if ( !types ) {
      types = new Set();
      for ( const [key, { shortRest }] of Object.entries(CONFIG.DND5E.spellcastingTypes) ) {
        if ( recoverLong || (recoverShort && shortRest) ) types.add(key);
      }
    }
    for ( const [key, slot] of Object.entries(this.system.spells) ) {
      if ( !types.has(slot.type) ) continue;
      result.updateData[`system.spells.${key}.value`] = slot.max;
    }
  }

  /* -------------------------------------------- */

  /**
   * Recovers item uses during short or long rests.
   * @param {object} [config]
   * @param {boolean} [config.recoverShortRestUses=true]  Recover uses for items that recharge after a short rest.
   * @param {boolean} [config.recoverLongRestUses=true]   Recover uses for items that recharge after a long rest.
   * @param {boolean} [config.recoverDailyUses=true]      Recover uses for items that recharge on a new day.
   * @param {RestResult} [result={}]                      Rest result being constructed.
   * @protected
   */
  async _getRestItemUsesRecovery({
    recoverShortRestUses, recoverLongRestUses, recoverDailyUses, ...config
  }={}, result={}) {
    const restConfig = CONFIG.DND5E.restTypes[config.type];
    const recovery = Array.from(restConfig.recoverPeriods ?? []);
    if ( recoverShortRestUses ) recovery.unshift("sr");
    if ( recoverLongRestUses ) recovery.unshift("lr");
    if ( recoverDailyUses || config.newDay ) recovery.unshift("day", "dawn", "dusk");

    result.updateItems ??= [];
    result.rolls ??= [];
    for ( const item of this.items ) {
      if ( foundry.utils.getType(item.system.recoverUses) !== "function" ) continue;
      const rollData = item.getRollData();
      const { updates, rolls } = await item.system.recoverUses(recovery, rollData);
      if ( !foundry.utils.isEmpty(updates) ) {
        const updateTarget = result.updateItems.find(i => i._id === item.id);
        if ( updateTarget ) foundry.utils.mergeObject(updateTarget, updates);
        else result.updateItems.push({ _id: item.id, ...updates });
      }
      result.rolls.push(...rolls);
    }
  }

  /* -------------------------------------------- */
  /*  Property Attribution                        */
  /* -------------------------------------------- */

  /**
   * Format an HTML breakdown for a given property.
   * @param {string} attribution      The property.
   * @param {object} [options]
   * @param {string} [options.title]  A title for the breakdown.
   * @returns {Promise<string>}
   */
  async getAttributionData(attribution, { title }={}) {
    switch ( attribution ) {
      case "attributes.ac": return this._prepareArmorClassAttribution({ title });
      case "attributes.movement": return this._prepareMovementAttribution();
      default: return "";
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare a movement breakdown.
   * @returns {string}
   * @protected
   */
  _prepareMovementAttribution() {
    const { movement } = this.system.attributes;
    const units = movement.units || defaultUnits("length");
    return Object.entries(CONFIG.DND5E.movementTypes).reduce((html, [k, label]) => {
      const value = movement[k];
      if ( value || (k === "walk") ) html += `
        <div class="row">
          <i class="fas ${k}"></i>
          <span class="value">${value ?? 0} <span class="units">${units}</span></span>
          <span class="label">${label}</span>
        </div>
      `;
      return html;
    }, "");
  }

  /* -------------------------------------------- */

  /**
   * Prepare an AC breakdown.
   * @param {object} [options]
   * @param {string} [options.title]  A title for the breakdown.
   * @returns {Promise<string>}
   * @protected
   */
  async _prepareArmorClassAttribution({ title }={}) {
    const rollData = this.getRollData({ deterministic: true });
    const ac = rollData.attributes.ac;
    const cfg = CONFIG.DND5E.armorClasses[ac.calc];
    const attribution = [];

    if ( ac.calc === "flat" ) {
      attribution.push({
        label: game.i18n.localize("DND5E.ArmorClassFlat"),
        mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
        value: ac.flat
      });
      return new PropertyAttribution(this, attribution, "attributes.ac", { title }).renderTooltip();
    }

    // Base AC Attribution
    switch ( ac.calc ) {

      // Natural armor
      case "natural":
        attribution.push({
          label: game.i18n.localize("DND5E.ArmorClassNatural"),
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: ac.flat
        });
        break;

      default:
        const formula = ac.calc === "custom" ? ac.formula : cfg.formula;
        let base = ac.base;
        const dataRgx = new RegExp(/@([a-z.0-9_-]+)/gi);
        for ( const [match, term] of formula.matchAll(dataRgx) ) {
          const value = String(foundry.utils.getProperty(rollData, term));
          if ( (term === "attributes.ac.armor") || (value === "0") ) continue;
          if ( Number.isNumeric(value) ) base -= Number(value);
          attribution.push({
            label: match,
            mode: CONST.ACTIVE_EFFECT_MODES.ADD,
            value
          });
        }
        const armorInFormula = formula.includes("@attributes.ac.armor");
        let label = game.i18n.localize("DND5E.PropertyBase");
        if ( armorInFormula ) label = this.armor?.name ?? game.i18n.localize("DND5E.ArmorClassUnarmored");
        attribution.unshift({
          label,
          mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
          value: base
        });
        break;
    }

    // Shield
    if ( ac.shield !== 0 ) attribution.push({
      label: this.shield?.name ?? game.i18n.localize("DND5E.EquipmentShield"),
      mode: CONST.ACTIVE_EFFECT_MODES.ADD,
      value: ac.shield
    });

    // Bonus
    if ( ac.bonus !== 0 ) attribution.push(...this._prepareActiveEffectAttributions("system.attributes.ac.bonus"));

    // Cover
    if ( ac.cover !== 0 ) attribution.push({
      label: game.i18n.localize("DND5E.Cover"),
      mode: CONST.ACTIVE_EFFECT_MODES.ADD,
      value: ac.cover
    });

    if ( attribution.length ) {
      return new PropertyAttribution(this, attribution, "attributes.ac", { title }).renderTooltip();
    }

    return "";
  }

  /* -------------------------------------------- */

  /**
   * Break down all of the Active Effects affecting a given target property.
   * @param {string} target               The data property being targeted.
   * @returns {AttributionDescription[]}  Any active effects that modify that property.
   * @protected
   */
  _prepareActiveEffectAttributions(target) {
    const rollData = this.getRollData({ deterministic: true });
    const attributions = [];
    for ( const e of this.allApplicableEffects() ) {
      let source = e.sourceName;
      if ( !e.origin || (e.origin === this.uuid) ) source = e.name;
      if ( !source || e.disabled || e.isSuppressed ) continue;
      const value = e.changes.reduce((n, change) => {
        if ( change.key !== target ) return n;
        if ( change.mode !== CONST.ACTIVE_EFFECT_MODES.ADD ) return n;
        return n + simplifyBonus(change.value, rollData);
      }, 0);
      if ( value ) attributions.push({ value, label: source, document: e, mode: CONST.ACTIVE_EFFECT_MODES.ADD });
    }
    return attributions;
  }

  /* -------------------------------------------- */
  /*  Conversion & Transformation                 */
  /* -------------------------------------------- */

  /**
   * Fetch stats from the original actor for data preparation.
   * @returns {{ originalSaves: object|null, originalSkills: object|null }}
   */
  getOriginalStats() {
    // Retrieve data for polymorphed actors
    let originalSaves = null;
    let originalSkills = null;
    if ( this.isPolymorphed ) {
      const transformOptions = this.flags.dnd5e?.transformOptions;
      const original = game.actors?.get(this.flags.dnd5e?.originalActor);
      if ( original ) {
        if ( transformOptions.mergeSaves ) originalSaves = original.system.abilities;
        if ( transformOptions.mergeSkills ) originalSkills = original.system.skills;
      }
    }
    return { originalSaves, originalSkills };
  }

  /* -------------------------------------------- */

  /**
   * Options that determine what properties of the original actor are kept and which are replaced with
   * the target actor.
   *
   * @typedef {object} TransformationOptions
   * @property {boolean} [keepPhysical=false]       Keep physical abilities (str, dex, con)
   * @property {boolean} [keepMental=false]         Keep mental abilities (int, wis, cha)
   * @property {boolean} [keepSaves=false]          Keep saving throw proficiencies
   * @property {boolean} [keepSkills=false]         Keep skill proficiencies
   * @property {boolean} [mergeSaves=false]         Take the maximum of the save proficiencies
   * @property {boolean} [mergeSkills=false]        Take the maximum of the skill proficiencies
   * @property {boolean} [keepClass=false]          Keep proficiency bonus
   * @property {boolean} [keepFeats=false]          Keep features
   * @property {boolean} [keepSpells=false]         Keep spells and spellcasting ability
   * @property {boolean} [keepItems=false]          Keep items
   * @property {boolean} [keepBio=false]            Keep biography
   * @property {boolean} [keepVision=false]         Keep vision
   * @property {boolean} [keepSelf=false]           Keep self
   * @property {boolean} [keepAE=false]             Keep all effects
   * @property {boolean} [keepOriginAE=true]        Keep effects which originate on this actor
   * @property {boolean} [keepOtherOriginAE=true]   Keep effects which originate on another actor
   * @property {boolean} [keepSpellAE=true]         Keep effects which originate from actors spells
   * @property {boolean} [keepFeatAE=true]          Keep effects which originate from actors features
   * @property {boolean} [keepEquipmentAE=true]     Keep effects which originate on actors equipment
   * @property {boolean} [keepClassAE=true]         Keep effects which originate from actors class/subclass
   * @property {boolean} [keepBackgroundAE=true]    Keep effects which originate from actors background
   * @property {boolean} [keepHP=false]             Keep HP & HD
   * @property {boolean} [keepType=false]           Keep creature type
   * @property {boolean} [addTemp=false]            Add temporary hit points equal to the target's max HP
   * @property {boolean} [transformTokens=true]     Transform linked tokens too
   * @property {string} [preset]                    The transformation preset used (if any).
   */

  /**
   * Transform this Actor into another one.
   *
   * @param {Actor5e} target                           The target Actor.
   * @param {TransformationOptions} [options={}]       Options that determine how the transformation is performed.
   * @param {object} [sheetOptions]
   * @param {boolean} [sheetOptions.renderSheet=true]  Render the sheet of the transformed actor after the polymorph
   * @returns {Promise<Array<Token>>|null}             Updated token if the transformation was performed.
   */
  async transformInto(target, { keepPhysical=false, keepMental=false, keepSaves=false, keepSkills=false,
    mergeSaves=false, mergeSkills=false, keepClass=false, keepFeats=false, keepSpells=false, keepItems=false,
    keepBio=false, keepVision=false, keepSelf=false, keepAE=false, keepOriginAE=true, keepOtherOriginAE=true,
    keepSpellAE=true, keepEquipmentAE=true, keepFeatAE=true, keepClassAE=true, keepBackgroundAE=true,
    keepHP=false, keepType=false, addTemp=false, transformTokens=true, preset}={}, {renderSheet=true}={}) {

    // Ensure the player is allowed to polymorph
    const allowed = game.settings.get("dnd5e", "allowPolymorphing");
    if ( !allowed && !game.user.isGM ) {
      ui.notifications.warn("DND5E.PolymorphWarn", {localize: true});
      return null;
    }

    // Get the original Actor data and the new source data
    const o = this.toObject();
    o.flags.dnd5e = o.flags.dnd5e || {};
    o.flags.dnd5e.transformOptions = {mergeSkills, mergeSaves};
    const source = target.toObject();

    if ( keepSelf ) {
      o.img = source.img;
      o.name = `${o.name} (${game.i18n.localize("DND5E.PolymorphSelf")})`;
    }

    // Prepare new data to merge from the source
    const d = foundry.utils.mergeObject(foundry.utils.deepClone({
      type: o.type, // Remain the same actor type
      name: `${o.name} (${source.name})`, // Append the new shape to your old name
      system: source.system, // Get the systemdata model of your new form
      items: source.items, // Get the items of your new form
      effects: o.effects.concat(source.effects), // Combine active effects from both forms
      img: source.img, // New appearance
      ownership: o.ownership, // Use the original actor permissions
      folder: o.folder, // Be displayed in the same sidebar folder
      flags: o.flags, // Use the original actor flags
      prototypeToken: { name: `${o.name} (${source.name})`, texture: {}, sight: {}, detectionModes: [] } // Set a new empty token
    }), keepSelf ? o : {}); // Keeps most of original actor

    // Specifically delete some data attributes
    delete d.system.resources; // Don't change your resource pools
    delete d.system.currency; // Don't lose currency
    delete d.system.bonuses; // Don't lose global bonuses
    if ( keepSpells ) delete d.system.attributes.spellcasting; // Keep spellcasting ability if retaining spells.

    // Specific additional adjustments
    d.system.details.alignment = o.system.details.alignment; // Don't change alignment
    d.system.attributes.exhaustion = o.system.attributes.exhaustion; // Keep your prior exhaustion level
    d.system.attributes.inspiration = o.system.attributes.inspiration; // Keep inspiration
    d.system.spells = o.system.spells; // Keep spell slots
    d.system.attributes.ac.flat = target.system.attributes.ac.value; // Override AC

    // Token appearance updates
    for ( const k of ["width", "height", "alpha", "lockRotation"] ) {
      d.prototypeToken[k] = source.prototypeToken[k];
    }
    for ( const k of ["offsetX", "offsetY", "scaleX", "scaleY", "src", "tint"] ) {
      d.prototypeToken.texture[k] = source.prototypeToken.texture[k];
    }
    d.prototypeToken.ring = source.prototypeToken.ring;
    for ( const k of ["bar1", "bar2", "displayBars", "displayName", "disposition", "rotation", "elevation"] ) {
      d.prototypeToken[k] = o.prototypeToken[k];
    }

    if ( !keepSelf ) {
      const sightSource = keepVision ? o.prototypeToken : source.prototypeToken;
      for ( const k of ["range", "angle", "visionMode", "color", "attenuation", "brightness", "saturation", "contrast"] ) {
        d.prototypeToken.sight[k] = sightSource.sight[k];
      }
      d.prototypeToken.sight.enabled = o.prototypeToken.sight.enabled;
      d.prototypeToken.detectionModes = sightSource.detectionModes;

      // Transfer ability scores
      const abilities = d.system.abilities;
      for ( let k of Object.keys(abilities) ) {
        const oa = o.system.abilities[k];
        const prof = abilities[k].proficient;
        const type = CONFIG.DND5E.abilities[k]?.type;
        if ( keepPhysical && (type === "physical") ) abilities[k] = oa;
        else if ( keepMental && (type === "mental") ) abilities[k] = oa;

        // Set saving throw proficiencies.
        if ( keepSaves && oa ) abilities[k].proficient = oa.proficient;
        else if ( mergeSaves && oa ) abilities[k].proficient = Math.max(prof, oa.proficient);
        else abilities[k].proficient = source.system.abilities[k].proficient;
      }

      // Transfer skills
      if ( keepSkills ) d.system.skills = o.system.skills;
      else if ( mergeSkills ) {
        for ( let [k, s] of Object.entries(d.system.skills) ) {
          s.value = Math.max(s.value, o.system.skills[k]?.value ?? 0);
        }
      }

      // Keep specific items from the original data
      d.items = d.items.concat(o.items.filter(i => {
        if ( ["class", "subclass"].includes(i.type) ) return keepClass || keepHP;
        else if ( i.type === "feat" ) return keepFeats;
        else if ( i.type === "spell" ) return keepSpells;
        else if ( i.type === "race" ) return keepType;
        else return keepItems;
      }));

      // Transfer classes for NPCs
      if ( !keepClass && ("cr" in d.system.details) ) {
        if ( keepHP ) {
          let profOverride = d.effects.findSplice(e => e._id === staticID("dnd5eTransformProf"));
          if ( !profOverride ) profOverride = new ActiveEffect.implementation({
            _id: staticID("dnd5eTransformProf"),
            name: game.i18n.localize("DND5E.Proficiency"),
            img: "icons/skills/social/diplomacy-peace-alliance.webp",
            disabled: false
          }).toObject();
          profOverride.changes = [{
            key: "system.attributes.prof",
            mode: CONST.ACTIVE_EFFECT_MODES.OVERRIDE,
            value: target.system.attributes.prof
          }];
          d.effects.push(profOverride);
        } else {
          const cls = new dnd5e.dataModels.item.ClassData({ levels: d.system.details.cr });
          d.items.push({
            type: "class",
            name: game.i18n.localize("DND5E.PolymorphTmpClass"),
            system: cls.toObject()
          });
        }
      }

      // Keep biography
      if ( keepBio ) d.system.details.biography = o.system.details.biography;

      // Keep senses
      if ( keepVision ) d.system.traits.senses = o.system.traits.senses;

      // Keep creature type
      if ( keepType ) d.system.details.type = o.system.details.type;

      // Keep HP & HD
      if ( keepHP ) d.system.attributes.hp = { ...this.system.attributes.hp };

      // Add temporary hit points
      if ( addTemp ) d.system.attributes.hp.temp = target.system.attributes.hp.max;

      // Remove active effects
      const oEffects = foundry.utils.deepClone(d.effects);
      const originEffectIds = new Set(oEffects.filter(effect => {
        return !effect.origin || effect.origin === this.uuid;
      }).map(e => e._id));
      d.effects = d.effects.filter(e => {
        if ( keepAE ) return true;
        if ( keepHP && !keepClass && (e._id === staticID("dnd5eTransformProf")) ) return true;
        const origin = e.origin?.startsWith("Actor") || e.origin?.startsWith("Item") ? fromUuidSync(e.origin) : {};
        const originIsSelf = origin?.parent?.uuid === this.uuid;
        const isOriginEffect = originEffectIds.has(e._id);
        if ( isOriginEffect ) return keepOriginAE;
        if ( !isOriginEffect && !originIsSelf ) return keepOtherOriginAE;
        if ( origin.type === "spell" ) return keepSpellAE;
        if ( origin.type === "feat" ) return keepFeatAE;
        if ( origin.type === "background" ) return keepBackgroundAE;
        if ( ["subclass", "class"].includes(origin.type) ) return keepClassAE;
        if ( ["equipment", "weapon", "tool", "loot", "container"].includes(origin.type) ) return keepEquipmentAE;
        return true;
      });
    }

    // Set a random image if source is configured that way
    if ( source.prototypeToken.randomImg ) {
      const images = await target.getTokenImages();
      d.prototypeToken.texture.src = images[Math.floor(Math.random() * images.length)];
    }

    // Set new data flags
    if ( !this.isPolymorphed || !d.flags.dnd5e.originalActor ) d.flags.dnd5e.originalActor = this.id;
    d.flags.dnd5e.isPolymorphed = true;

    // Gather previous actor data
    const previousActorIds = this.getFlag("dnd5e", "previousActorIds") || [];
    previousActorIds.push(this._id);
    foundry.utils.setProperty(d.flags, "dnd5e.previousActorIds", previousActorIds);

    // Update unlinked Tokens, and grab a copy of any actorData adjustments to re-apply
    if ( this.isToken ) {
      const tokenData = d.prototypeToken;
      delete d.prototypeToken;
      tokenData.delta = d;
      tokenData.elevation = this.token.elevation;
      tokenData.rotation = this.token.rotation;
      const previousActorData = this.token.delta.toObject();
      foundry.utils.setProperty(tokenData, "flags.dnd5e.previousActorData", previousActorData);
      await this.sheet?.close();
      const update = await this.token.update(tokenData);
      if ( renderSheet ) this.sheet?.render(true);
      return update;
    }

    // Close sheet for non-transformed Actor
    await this.sheet?.close();

    /**
     * A hook event that fires just before the actor is transformed.
     * @function dnd5e.transformActor
     * @memberof hookEvents
     * @param {Actor5e} actor                  The original actor before transformation.
     * @param {Actor5e} target                 The target actor into which to transform.
     * @param {object} data                    The data that will be used to create the new transformed actor.
     * @param {TransformationOptions} options  Options that determine how the transformation is performed.
     * @param {object} [options]
     */
    Hooks.callAll("dnd5e.transformActor", this, target, d, {
      keepPhysical, keepMental, keepSaves, keepSkills, mergeSaves, mergeSkills, keepClass, keepFeats, keepSpells,
      keepItems, keepBio, keepVision, keepSelf, keepAE, keepOriginAE, keepOtherOriginAE, keepSpellAE,
      keepEquipmentAE, keepFeatAE, keepClassAE, keepBackgroundAE, keepHP, keepType, addTemp, transformTokens, preset
    }, {renderSheet});

    // Create new Actor with transformed data
    const newActor = await this.constructor.create(d, {renderSheet});

    // Update placed Token instances
    if ( !transformTokens ) return;
    const tokens = this.getActiveTokens(true);
    const updates = tokens.map(t => {
      const newTokenData = foundry.utils.deepClone(d.prototypeToken);
      newTokenData._id = t.id;
      newTokenData.actorId = newActor.id;
      newTokenData.actorLink = true;
      newTokenData.elevation = t.document.elevation;
      newTokenData.rotation = t.document.rotation;

      const dOriginalActor = foundry.utils.getProperty(d, "flags.dnd5e.originalActor");
      foundry.utils.setProperty(newTokenData, "flags.dnd5e.originalActor", dOriginalActor);
      foundry.utils.setProperty(newTokenData, "flags.dnd5e.isPolymorphed", true);
      return newTokenData;
    });
    return canvas.scene?.updateEmbeddedDocuments("Token", updates);
  }

  /* -------------------------------------------- */

  /**
   * If this actor was transformed with transformTokens enabled, then its
   * active tokens need to be returned to their original state. If not, then
   * we can safely just delete this actor.
   * @param {object} [options]
   * @param {boolean} [options.renderSheet=true]  Render Sheet after revert the transformation.
   * @returns {Promise<Actor>|null}  Original actor if it was reverted.
   */
  async revertOriginalForm({renderSheet=true}={}) {
    if ( !this.isPolymorphed ) return;
    if ( !this.isOwner ) {
      ui.notifications.warn("DND5E.PolymorphRevertWarn", {localize: true});
      return null;
    }

    /**
     * A hook event that fires just before the actor is reverted to original form.
     * @function dnd5e.revertOriginalForm
     * @memberof hookEvents
     * @param {Actor} this                 The original actor before transformation.
     * @param {object} [options]
     */
    Hooks.callAll("dnd5e.revertOriginalForm", this, {renderSheet});
    const previousActorIds = this.getFlag("dnd5e", "previousActorIds") ?? [];
    const isOriginalActor = !previousActorIds.length;
    const isRendered = this.sheet.rendered;

    // Obtain a reference to the original actor
    const original = game.actors.get(this.getFlag("dnd5e", "originalActor"));

    // If we are reverting an unlinked token, grab the previous actorData, and create a new token
    if ( this.isToken ) {
      const baseActor = original ? original : game.actors.get(this.token.actorId);
      if ( !baseActor ) {
        ui.notifications.warn(game.i18n.format("DND5E.PolymorphRevertNoOriginalActorWarn", {
          reference: this.getFlag("dnd5e", "originalActor")
        }));
        return;
      }
      const prototypeTokenData = (await baseActor.getTokenDocument()).toObject();
      const actorData = this.token.getFlag("dnd5e", "previousActorData");
      const tokenUpdate = this.token.toObject();
      actorData._id = tokenUpdate.delta._id;
      tokenUpdate.delta = actorData;

      for ( const k of ["width", "height", "alpha", "lockRotation", "name"] ) {
        tokenUpdate[k] = prototypeTokenData[k];
      }
      for ( const k of ["offsetX", "offsetY", "scaleX", "scaleY", "src", "tint"] ) {
        tokenUpdate.texture[k] = prototypeTokenData.texture[k];
      }
      tokenUpdate.ring = prototypeTokenData.ring;
      tokenUpdate.sight = prototypeTokenData.sight;
      tokenUpdate.detectionModes = prototypeTokenData.detectionModes;

      await this.sheet.close();
      await canvas.scene?.deleteEmbeddedDocuments("Token", [this.token._id]);
      const token = await TokenDocument.implementation.create(tokenUpdate, {
        parent: canvas.scene, keepId: true, render: true
      });
      if ( isOriginalActor ) {
        await this.unsetFlag("dnd5e", "isPolymorphed");
        await this.unsetFlag("dnd5e", "previousActorIds");
        await this.token.unsetFlag("dnd5e", "previousActorData");
      }
      if ( isRendered && renderSheet ) token.actor?.sheet?.render(true);
      return token;
    }

    if ( !original ) {
      ui.notifications.warn(game.i18n.format("DND5E.PolymorphRevertNoOriginalActorWarn", {
        reference: this.getFlag("dnd5e", "originalActor")
      }));
      return;
    }

    // Get the Tokens which represent this actor
    if ( canvas.ready ) {
      const tokens = this.getActiveTokens(true);
      const tokenData = (await original.getTokenDocument()).toObject();
      const tokenUpdates = tokens.map(t => {
        const update = foundry.utils.deepClone(tokenData);
        update._id = t.id;
        update.elevation = t.document.elevation;
        update.rotation = t.document.rotation;
        delete update.x;
        delete update.y;
        return update;
      });
      await canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates, { diff: false, recursive: false });
    }
    if ( isOriginalActor ) {
      await this.unsetFlag("dnd5e", "isPolymorphed");
      await this.unsetFlag("dnd5e", "previousActorIds");
    }

    // Delete the polymorphed version(s) of the actor, if possible
    if ( game.user.isGM ) {
      const idsToDelete = previousActorIds.filter(id =>
        id !== original.id // Is not original Actor Id
        && game.actors?.get(id) // Actor still exists
      ).concat([this.id]); // Add this id

      await Actor.implementation.deleteDocuments(idsToDelete);
    } else if ( isRendered ) {
      this.sheet?.close();
    }
    if ( isRendered && renderSheet ) original.sheet?.render(isRendered);
    return original;
  }

  /* -------------------------------------------- */

  /**
   * Add additional system-specific sidebar directory context menu options for Actor documents
   * @param {jQuery} html         The sidebar HTML
   * @param {Array} entryOptions  The default array of context menu options
   */
  static addDirectoryContextOptions(html, entryOptions) {
    entryOptions.push({
      name: "DND5E.PolymorphRestoreTransformation",
      icon: '<i class="fa-solid fa-backward"></i>',
      callback: li => {
        const actor = game.actors.get(li.data("documentId"));
        return actor.revertOriginalForm();
      },
      condition: li => {
        const allowed = game.settings.get("dnd5e", "allowPolymorphing");
        if ( !allowed && !game.user.isGM ) return false;
        const actor = game.actors.get(li.data("documentId"));
        return actor && actor.isPolymorphed;
      },
      group: "system"
    }, {
      name: "DND5E.Group.Primary.Set",
      icon: '<i class="fa-solid fa-star"></i>',
      callback: li => {
        game.settings.set("dnd5e", "primaryParty", { actor: game.actors.get(li[0].dataset.documentId) });
      },
      condition: li => {
        const actor = game.actors.get(li[0].dataset.documentId);
        const primary = game.settings.get("dnd5e", "primaryParty")?.actor;
        return game.user.isGM && (actor.type === "group")
          && (actor.system.type.value === "party") && (actor !== primary);
      },
      group: "system"
    }, {
      name: "DND5E.Group.Primary.Remove",
      icon: '<i class="fa-regular fa-star"></i>',
      callback: li => {
        game.settings.set("dnd5e", "primaryParty", { actor: null });
      },
      condition: li => {
        const actor = game.actors.get(li[0].dataset.documentId);
        const primary = game.settings.get("dnd5e", "primaryParty")?.actor;
        return game.user.isGM && (actor === primary);
      },
      group: "system"
    });
  }

  /* -------------------------------------------- */

  /**
   * Add class to actor entry representing the primary group.
   * @param {jQuery} jQuery
   */
  static onRenderActorDirectory(jQuery) {
    const primaryParty = game.settings.get("dnd5e", "primaryParty")?.actor;
    if ( primaryParty ) {
      const element = jQuery[0]?.querySelector(`[data-entry-id="${primaryParty.id}"]`);
      element?.classList.add("primary-party");
    }
  }

  /* -------------------------------------------- */

  /**
   * Format a type object into a string.
   * @param {object} typeData          The type data to convert to a string.
   * @returns {string}
   */
  static formatCreatureType(typeData) {
    if ( typeof typeData === "string" ) return typeData; // Backwards compatibility
    let localizedType;
    if ( typeData.value === "custom" ) {
      localizedType = typeData.custom;
    } else if ( typeData.value in CONFIG.DND5E.creatureTypes ) {
      const code = CONFIG.DND5E.creatureTypes[typeData.value];
      localizedType = game.i18n.localize(typeData.swarm ? code.plural : code.label);
    }
    let type = localizedType;
    if ( typeData.swarm ) {
      type = game.i18n.format("DND5E.CreatureSwarmPhrase", {
        size: game.i18n.localize(CONFIG.DND5E.actorSizes[typeData.swarm].label),
        type: localizedType
      });
    }
    if (typeData.subtype) type = `${type} (${typeData.subtype})`;
    return type;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdate(data, options, userId) {
    super._onUpdate(data, options, userId);

    const isHpUpdate = !!data.system?.attributes?.hp;

    if ( userId === game.userId ) {
      if ( isHpUpdate ) await this.updateBloodied(options);
      await this.updateEncumbrance(options);
      this._onUpdateExhaustion(data, options);
    }

    const hp = options.dnd5e?.hp;
    if ( isHpUpdate && hp && !options.isRest && !options.isAdvancement ) {
      const curr = this.system.attributes.hp;
      const changes = {
        hp: curr.value - hp.value,
        temp: curr.temp - hp.temp
      };
      changes.total = changes.hp + changes.temp;

      if ( Number.isInteger(changes.total) && (changes.total !== 0) ) {
        this._displayTokenEffect(changes);
        if ( !game.settings.get("dnd5e", "disableConcentration") && (userId === game.userId) && (changes.total < 0)
          && (options.dnd5e?.concentrationCheck !== false) && (curr.value < curr.effectiveMax) ) {
          this.challengeConcentration({ dc: this.getConcentrationDC(-changes.total) });
        }

        /**
         * A hook event that fires when an actor is damaged or healed by any means. The actual name
         * of the hook will depend on the change in hit points.
         * @function dnd5e.damageActor
         * @memberof hookEvents
         * @param {Actor5e} actor                                       The actor that had their hit points reduced.
         * @param {{hp: number, temp: number, total: number}} changes   The changes to hit points.
         * @param {object} update                                       The original update delta.
         * @param {string} userId                                       Id of the user that performed the update.
         */
        Hooks.callAll(`dnd5e.${changes.total > 0 ? "heal" : "damage"}Actor`, this, changes, data, userId);
      }
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);

    const origin = this.getFlag("dnd5e", "summon.origin");
    // TODO: Replace with parseUuid once V11 support is dropped
    if ( origin ) dnd5e.registry.summons.untrack(origin.split(".Item.")[0], this.uuid);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCreateDescendantDocuments(parent, collection, documents, data, options, userId) {
    if ( (userId === game.userId) && (collection === "items") ) await this.updateEncumbrance(options);
    super._onCreateDescendantDocuments(parent, collection, documents, data, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
    if ( (userId === game.userId) && (collection === "items") ) await this.updateEncumbrance(options);
    super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId) {
    if ( (userId === game.userId) ) {
      if ( collection === "items" ) await this.updateEncumbrance(options);
      await this._clearFavorites(documents);
    }
    super._onDeleteDescendantDocuments(parent, collection, documents, ids, options, userId);
  }

  /* -------------------------------------------- */

  /**
   * Flash ring & display changes to health as scrolling combat text.
   * @param {object} changes          Object of changes to hit points.
   * @param {number} changes.hp       Changes to `hp.value`.
   * @param {number} changes.temp     The change to `hp.temp`.
   * @param {number} changes.total    The total change to hit points.
   * @protected
   */
  _displayTokenEffect(changes) {
    let key;
    let value;
    if ( changes.hp < 0 ) {
      key = "damage";
      value = changes.total;
    } else if ( changes.hp > 0 ) {
      key = "healing";
      value = changes.total;
    } else if ( changes.temp ) {
      key = "temp";
      value = changes.temp;
    }
    if ( !key || !value ) return;

    const tokens = this.isToken ? [this.token] : this.getActiveTokens(true, true);
    if ( !tokens.length ) return;

    const pct = Math.clamp(Math.abs(value) / this.system.attributes.hp.max, 0, 1);
    const fill = CONFIG.DND5E.tokenHPColors[key];

    for ( const token of tokens ) {
      if ( !token.object?.visible || token.isSecret ) continue;
      if ( token.hasDynamicRing ) token.flashRing(key);
      const t = token.object;
      canvas.interface.createScrollingText(t.center, value.signedString(), {
        anchor: CONST.TEXT_ANCHOR_POINTS.TOP,
        // Adapt the font size relative to the Actor's HP total to emphasize more significant blows
        fontSize: 16 + (32 * pct), // Range between [16, 48]
        fill: fill,
        stroke: 0x000000,
        strokeThickness: 4,
        jitter: 0.25
      });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async toggleStatusEffect(statusId, options) {
    const created = await super.toggleStatusEffect(statusId, options);
    const status = CONFIG.statusEffects.find(e => e.id === statusId);
    if ( !(created instanceof ActiveEffect) || !status.exclusiveGroup ) return created;

    const others = CONFIG.statusEffects
      .filter(e => (e.id !== statusId) && (e.exclusiveGroup === status.exclusiveGroup) && this.effects.has(e._id));
    if ( others.length ) await this.deleteEmbeddedDocuments("ActiveEffect", others.map(e => e._id));

    return created;
  }

  /* -------------------------------------------- */

  /**
   * TODO: Perform this as part of Actor._preUpdateOperation instead when it becomes available in v12.
   * Handle syncing the Actor's exhaustion level with the ActiveEffect.
   * @param {object} data                          The Actor's update delta.
   * @param {DocumentModificationContext} options  Additional options supplied with the update.
   * @returns {Promise<ActiveEffect|void>}
   * @protected
   */
  async _onUpdateExhaustion(data, options) {
    const level = foundry.utils.getProperty(data, "system.attributes.exhaustion");
    if ( !Number.isFinite(level) ) return;
    let effect = this.effects.get(ActiveEffect5e.ID.EXHAUSTION);
    if ( level < 1 ) return effect?.delete();
    else if ( effect ) {
      const originalExhaustion = foundry.utils.getProperty(options, "dnd5e.originalExhaustion");
      return effect.update({ "flags.dnd5e.exhaustionLevel": level }, { dnd5e: { originalExhaustion } });
    } else {
      effect = await ActiveEffect.implementation.fromStatusEffect("exhaustion", { parent: this });
      effect.updateSource({ "flags.dnd5e.exhaustionLevel": level });
      return ActiveEffect.implementation.create(effect, { parent: this, keepId: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle applying/removing the bloodied status.
   * @param {DocumentModificationContext} options  Additional options supplied with the update.
   * @returns {Promise<ActiveEffect>|void}
   */
  updateBloodied(options) {
    const hp = this.system.attributes?.hp;
    if ( !hp?.effectiveMax || (game.settings.get("dnd5e", "bloodied") === "none") ) return;

    const effect = this.effects.get(ActiveEffect5e.ID.BLOODIED);
    if ( hp.value > hp.effectiveMax * CONFIG.DND5E.bloodied.threshold ) return effect?.delete();
    if ( effect ) return;

    return ActiveEffect.implementation.create({
      _id: ActiveEffect5e.ID.BLOODIED,
      name: game.i18n.localize(CONFIG.DND5E.bloodied.name),
      img: CONFIG.DND5E.bloodied.icon,
      statuses: ["bloodied"]
    }, { parent: this, keepId: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle applying/removing encumbrance statuses.
   * @param {DocumentModificationContext} options  Additional options supplied with the update.
   * @returns {Promise<ActiveEffect>|void}
   */
  updateEncumbrance(options) {
    const encumbrance = this.system.attributes?.encumbrance;
    if ( !encumbrance || (game.settings.get("dnd5e", "encumbrance") === "none") ) return;
    const statuses = [];
    const variant = game.settings.get("dnd5e", "encumbrance") === "variant";
    if ( encumbrance.value > encumbrance.thresholds.maximum ) statuses.push("exceedingCarryingCapacity");
    if ( (encumbrance.value > encumbrance.thresholds.heavilyEncumbered) && variant ) statuses.push("heavilyEncumbered");
    if ( (encumbrance.value > encumbrance.thresholds.encumbered) && variant ) statuses.push("encumbered");

    const effect = this.effects.get(ActiveEffect5e.ID.ENCUMBERED);
    if ( !statuses.length ) return effect?.delete();

    const effectData = { ...CONFIG.DND5E.encumbrance.effects[statuses[0]], statuses };
    if ( effect ) {
      const originalEncumbrance = effect.statuses.first();
      return effect.update(effectData, { dnd5e: { originalEncumbrance } });
    }

    return ActiveEffect.implementation.create(
      { _id: ActiveEffect5e.ID.ENCUMBERED, ...effectData },
      { parent: this, keepId: true }
    );
  }

  /* -------------------------------------------- */

  /**
   * Handle clearing favorited entries that were deleted.
   * @param {Document[]} documents  The deleted Documents.
   * @returns {Promise<Actor5e>|void}
   * @protected
   */
  _clearFavorites(documents) {
    if ( !("favorites" in this.system) ) return;
    const ids = new Set(documents.map(d => d.getRelativeUUID(this)));
    const favorites = this.system.favorites.filter(f => !ids.has(f.id));
    return this.update({ "system.favorites": favorites });
  }
}

/* -------------------------------------------- */

/**
 * @extends {Map<string, Set<Item5e>>}
 */
class SourcedItemsMap extends Map {
  /** @inheritDoc */
  get(key, { remap=true, legacy=true }={}) {
    if ( !key ) return;
    if ( remap ) ({ uuid: key } = parseUuid(key) ?? {});
    if ( legacy ) {
      foundry.utils.logCompatibilityWarning(
        "The `sourcedItems` data on actor has changed from storing individual items to storing Sets of items. Pass `legacy: false` to retrieve the new Set data.",
        { since: "DnD5e 4.1", until: "DnD5e 4.3", once: true }
      );
      return super.get(key)?.first();
    }
    return super.get(key);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  set(key, value) {
    const { uuid } = parseUuid(key);
    if ( !this.has(uuid) ) super.set(uuid, new Set());
    this.get(uuid, { remap: false, legacy: false }).add(value);
    return this;
  }

  /* -------------------------------------------- */

  /**
   * Adjust keys once compendium UUID redirects have been initialized.
   */
  _redirectKeys() {
    for ( const [key, value] of this.entries() ) {
      const { uuid } = parseUuid(key);
      if ( key !== uuid ) {
        this.set(uuid, value);
        this.delete(key);
      }
    }
  }
}

/**
 * Inline application that presents the player with a list of items to be added.
 */
class ItemGrantFlow extends AdvancementFlow {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/item-grant-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /**
   * Produce the rendering context for this flow.
   * @returns {object}
   */
  async getContext() {
    const config = this.advancement.configuration;
    const added = this.retainedData?.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId"))
      ?? this.advancement.value.added;
    const checked = new Set(Object.values(added ?? {}));
    return {
      optional: this.advancement.configuration.optional,
      items: config.items.map(i => {
        const item = foundry.utils.deepClone(fromUuidSync(i.uuid));
        if ( !item ) return null;
        item.checked = added ? checked.has(item.uuid) : (config.optional && !i.optional);
        item.optional = config.optional || i.optional;
        return item;
      }, []).filter(i => i),
      abilities: this.getSelectAbilities()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options={}) {
    return foundry.utils.mergeObject(super.getData(options), await this.getContext());
  }

  /* -------------------------------------------- */

  /**
   * Get the context information for selected spell abilities.
   * @returns {object}
   */
  getSelectAbilities() {
    const config = this.advancement.configuration;
    return {
      options: config.spell?.ability.size > 1 ? config.spell.ability.reduce((obj, k) => {
        obj[k] = CONFIG.DND5E.abilities[k]?.label;
        return obj;
      }, {}) : null,
      selected: this.ability ?? this.retainedData?.ability ?? this.advancement.value.ability
        ?? config.spell?.ability.first()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("a[data-uuid]").click(this._onClickFeature.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on a feature during item grant to preview the feature.
   * @param {MouseEvent} event  The triggering event.
   * @protected
   */
  async _onClickFeature(event) {
    event.preventDefault();
    const uuid = event.currentTarget.dataset.uuid;
    const item = await fromUuid(uuid);
    item?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    const retainedData = this.retainedData?.items.reduce((obj, i) => {
      obj[foundry.utils.getProperty(i, "flags.dnd5e.sourceId")] = i;
      return obj;
    }, {});
    await this.advancement.apply(this.level, formData, retainedData);
  }
}

/**
 * Inline application that presents the player with a choice of items.
 */
class ItemChoiceFlow extends ItemGrantFlow {

  /**
   * Currently selected ability.
   * @type {string}
   */
  ability;

  /**
   * Set of selected UUIDs.
   * @type {Set<string>}
   */
  selected;

  /**
   * Cached items from the advancement's pool.
   * @type {Item5e[]}
   */
  pool;

  /**
   * UUID of item to be replaced.
   * @type {string}
   */
  replacement;

  /**
   * List of dropped items.
   * @type {Item5e[]}
   */
  dropped;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      dragDrop: [{ dropSelector: ".drop-target" }],
      template: "systems/dnd5e/templates/advancement/item-choice-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async retainData(data) {
    await super.retainData(data);
    this.replacement = data.replaced?.original;
    this.selected = new Set(data.items.map(i => foundry.utils.getProperty(i, "flags.dnd5e.sourceId")));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getContext() {
    const context = {};
    this.selected ??= new Set(Object.values(this.advancement.value.added?.[this.level] ?? {}));
    this.pool ??= await Promise.all(this.advancement.configuration.pool.map(i => fromUuid(i.uuid)));
    if ( !this.dropped ) {
      this.dropped = [];
      for ( const data of this.retainedData?.items ?? [] ) {
        const uuid = foundry.utils.getProperty(data, "flags.dnd5e.sourceId");
        if ( this.pool.find(i => uuid === i.uuid) ) continue;
        const item = await fromUuid(uuid);
        item.dropped = true;
        this.dropped.push(item);
      }
    }

    const levelConfig = this.advancement.configuration.choices[this.level];
    let max = levelConfig.count ?? 0;
    context.replaceable = levelConfig.replacement;
    context.noReplacement = !this.advancement.actor.items.has(this.replacement);
    if ( context.replaceable && !context.noReplacement ) max++;
    if ( this.selected.size > max ) {
      const [kept, lost] = Array.from(Array.from(this.selected).entries()).reduce(([kept, lost], [index, value]) => {
        if ( index < max ) kept.push(value);
        else lost.push(value);
        return [kept, lost];
      }, [[], []]);
      this.selected = new Set(kept);
      this.dropped = this.dropped.filter(i => !lost.includes(i.uuid));
    }
    context.choices = { max, current: this.selected.size, full: this.selected.size >= max };

    context.previousLevels = {};
    const previouslySelected = new Set();
    for ( const level of Array.fromRange(this.level - 1, 1) ) {
      const added = this.advancement.value.added[level];
      if ( added ) context.previousLevels[level] = Object.entries(added).map(([id, uuid]) => {
        const item = fromUuidSync(uuid);
        previouslySelected.add(uuid);
        return {
          ...item, id, uuid,
          checked: id === this.replacement,
          replaced: false
        };
      });
      const replaced = this.advancement.value.replaced[level];
      if ( replaced ) {
        const match = context.previousLevels[replaced.level].find(v => v.id === replaced.original);
        if ( match ) {
          match.replaced = true;
          previouslySelected.delete(match.uuid);
        }
      }
    }

    context.items = [...this.pool, ...this.dropped].reduce((items, i) => {
      if ( i ) {
        i.checked = this.selected.has(i.uuid);
        i.disabled = !i.checked && context.choices.full;
        const validLevel = (i.system.prerequisites?.level ?? -Infinity) <= this.level;
        const available = !previouslySelected.has(i.uuid) || i.system.prerequisites?.repeatable;
        if ( available && validLevel ) items.push(i);
      }
      return items;
    }, []);

    context.abilities = this.getSelectAbilities();
    context.abilities.disabled = previouslySelected.size;
    this.ability ??= context.abilities.selected;

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".item-delete").click(this._onItemDelete.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeInput(event) {
    if ( event.target.tagName === "DND5E-CHECKBOX" ) {
      if ( event.target.checked ) this.selected.add(event.target.name);
      else this.selected.delete(event.target.name);
    }
    else if ( event.target.type === "radio" ) this.replacement = event.target.value;
    else if ( event.target.name === "ability" ) this.ability = event.target.value;
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a dropped item.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onItemDelete(event) {
    event.preventDefault();
    const uuidToDelete = event.currentTarget.closest(".item-name")?.querySelector("dnd5e-checkbox")?.name;
    if ( !uuidToDelete ) return;
    this.dropped.findSplice(i => i.uuid === uuidToDelete);
    this.selected.delete(uuidToDelete);
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    const levelConfig = this.advancement.configuration.choices[this.level];
    let max = levelConfig.count ?? 0;
    if ( levelConfig.replacement && this.advancement.actor.items.has(this.replacement) ) max++;
    if ( this.selected.size >= max ) return false;

    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData("text/plain"));
    } catch(err) {
      return false;
    }

    if ( data.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);

    try {
      this.advancement._validateItemType(item);
    } catch(err) {
      ui.notifications.error(err.message);
      return null;
    }

    // If the item is already been marked as selected, no need to go further
    if ( this.selected.has(item.uuid) ) return false;

    // Check to ensure the dropped item hasn't been selected at a lower level
    if ( item.system.prerequisites?.repeatable !== true ) {
      for ( const [level, data] of Object.entries(this.advancement.value.added ?? {}) ) {
        if ( level >= this.level ) continue;
        if ( Object.values(data).includes(item.uuid) ) {
          ui.notifications.error("DND5E.ADVANCEMENT.ItemChoice.Warning.PreviouslyChosen", { localize: true });
          return null;
        }
      }
    }

    // If a feature has a level pre-requisite, make sure it is less than or equal to current level
    if ( (item.system.prerequisites?.level ?? -Infinity) > this.level ) {
      ui.notifications.error(game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Warning.FeatureLevel", {
        level: item.system.prerequisites.level
      }));
      return null;
    }

    // If spell level is restricted to available level, ensure the spell is of the appropriate level
    const spellLevel = this.advancement.configuration.restriction.level;
    if ( (this.advancement.configuration.type === "spell") && spellLevel === "available" ) {
      const maxSlot = this._maxSpellSlotLevel();
      if ( item.system.level > maxSlot ) {
        ui.notifications.error(game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Warning.SpellLevelAvailable", {
          level: CONFIG.DND5E.spellLevels[maxSlot]
        }));
        return null;
      }
    }

    // Mark the item as selected
    this.selected.add(item.uuid);

    // If the item doesn't already exist in the pool, add it
    if ( !this.pool.find(i => i.uuid === item.uuid) ) {
      this.dropped.push(item);
      item.dropped = true;
    }

    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Determine the maximum spell slot level for the actor to which this advancement is being applied.
   * @returns {number}
   */
  _maxSpellSlotLevel() {
    const spellcasting = this.advancement.item.spellcasting;
    let spells;

    // For advancements on classes or subclasses, use the largest slot available for that class
    if ( spellcasting ) {
      const progression = { slot: 0, pact: {} };
      const maxSpellLevel = CONFIG.DND5E.SPELL_SLOT_TABLE[CONFIG.DND5E.SPELL_SLOT_TABLE.length - 1].length;
      spells = Object.fromEntries(Array.fromRange(maxSpellLevel, 1).map(l => [`spell${l}`, {}]));
      Actor5e.computeClassProgression(progression, this.advancement.item, { spellcasting });
      Actor5e.prepareSpellcastingSlots(spells, spellcasting.type, progression);
    }

    // For all other items, use the largest slot possible
    else spells = this.advancement.actor.system.spells;

    return Object.values(spells).reduce((slot, { max, level }) => {
      if ( !max ) return slot;
      return Math.max(slot, level || -1);
    }, 0);
  }
}

const { BooleanField: BooleanField$k, SchemaField: SchemaField$r, SetField: SetField$i, StringField: StringField$B } = foundry.data.fields;

class SpellConfigurationData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ability: new SetField$i(new StringField$B()),
      preparation: new StringField$B(),
      uses: new SchemaField$r({
        max: new FormulaField({ deterministic: true }),
        per: new StringField$B(),
        requireSlot: new BooleanField$k()
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    if ( foundry.utils.getType(source.ability) === "string" ) {
      source.ability = source.ability ? [source.ability] : [];
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Apply changes to a spell item based on this spell configuration.
   * @param {object} itemData          Data for the item to modify.
   * @param {object} [config={}]
   * @param {string} [config.ability]  Spellcasting ability selected during advancement process.
   */
  applySpellChanges(itemData, { ability }={}) {
    ability = this.ability.size ? this.ability.has(ability) ? ability : this.ability.first() : null;
    if ( ability ) foundry.utils.setProperty(itemData, "system.ability", ability);
    if ( this.preparation ) foundry.utils.setProperty(itemData, "system.preparation.mode", this.preparation);

    if ( this.uses.max && this.uses.per ) {
      foundry.utils.setProperty(itemData, "system.uses.max", this.uses.max);
      itemData.system.uses.recovery ??= [];
      itemData.system.uses.recovery.push({ period: this.uses.per, type: "recoverAll" });

      const preparationConfig = CONFIG.DND5E.spellPreparationModes[itemData.system.preparation?.mode];
      const createForwardActivity = !this.uses.requireSlot && preparationConfig?.upcast;

      for ( const activity of Object.values(itemData.system.activities ?? {}) ) {
        if ( !activity.consumption?.spellSlot ) continue;

        // Create a forward activity
        if ( createForwardActivity ) {
          const newActivity = {
            _id: foundry.utils.randomID(),
            type: "forward",
            name: `${activity.name ?? game.i18n.localize(
              CONFIG.DND5E.activityTypes[activity.type]?.documentClass.metadata.title
            )} (${game.i18n.localize("DND5E.ADVANCEMENT.SPELLCONFIG.FreeCasting").toLowerCase()})`,
            sort: (activity.sort ?? 0) + 1,
            activity: {
              id: activity._id
            },
            consumption: {
              targets: [{ type: "itemUses", target: "", value: "1" }]
            }
          };
          foundry.utils.setProperty(itemData, `system.activities.${newActivity._id}`, newActivity);
        }

        // Modify existing activity
        else {
          const activityData = foundry.utils.deepClone(activity);
          activityData.consumption.targets ??= [];
          activityData.consumption.targets.push({ type: "itemUses", target: "", value: "1" });
          foundry.utils.setProperty(itemData, `system.activities.${activityData._id}`, activityData);
        }
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Changes that this spell configuration indicates should be performed on spells.
   * @param {object} data  Data for the advancement process.
   * @returns {object}
   * @deprecated since DnD5e 4.0, available until DnD5e 4.4
   */
  getSpellChanges(data={}) {
    foundry.utils.logCompatibilityWarning(
      "The `getSpellChanges` method on `SpellConfigurationData` has been deprecated and replaced with `applySpellChanges`.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );
    const updates = {};
    if ( this.ability.size ) {
      updates["system.ability"] = this.ability.has(data.ability) ? data.ability : this.ability.first();
    }
    if ( this.preparation ) updates["system.preparation.mode"] = this.preparation;
    if ( this.uses.max && this.uses.per ) {
      updates["system.uses.max"] = this.uses.max;
      updates["system.uses.per"] = this.uses.per;
    }
    return updates;
  }
}

const {
  ArrayField: ArrayField$d, BooleanField: BooleanField$j, EmbeddedDataField: EmbeddedDataField$3, ForeignDocumentField: ForeignDocumentField$3, NumberField: NumberField$o, SchemaField: SchemaField$q, StringField: StringField$A
} = foundry.data.fields;

/**
 * Configuration data for choice levels.
 *
 * @typedef {object} ItemChoiceLevelConfig
 * @property {number} count         Number of items a player can select at this level.
 * @property {boolean} replacement  Can a player replace previous selections at this level?
 */

/**
 * Configuration data for an individual pool entry.
 *
 * @typedef {object} ItemChoicePoolEntry
 * @property {string} uuid  UUID of the item to present as a choice.
 */

/**
 * Configuration data for Item Choice advancement.
 *
 * @property {boolean} allowDrops                             Should players be able to drop non-listed items?
 * @property {Record<number, ItemChoiceLevelConfig>} choices  Choices & config for specific levels.
 * @property {ItemChoicePoolEntry[]} pool                     Items that can be chosen.
 * @property {object} restriction
 * @property {string} restriction.type                        Specific item type allowed.
 * @property {string} restriction.subtype                     Item sub-type allowed.
 * @property {"available"|number} restriction.level           Level of spell allowed.
 * @property {SpellConfigurationData} spell                   Mutations applied to spell items.
 * @property {string} type                                    Type of item allowed, if it should be restricted.
 */
class ItemChoiceConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ItemChoice", "DND5E.ADVANCEMENT.SPELLCONFIG"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      allowDrops: new BooleanField$j({ initial: true }),
      choices: new MappingField(new SchemaField$q({
        count: new NumberField$o({integer: true, min: 0}),
        replacement: new BooleanField$j()
      })),
      pool: new ArrayField$d(new SchemaField$q({ uuid: new StringField$A() })),
      restriction: new SchemaField$q({
        level: new StringField$A(),
        subtype: new StringField$A(),
        type: new StringField$A()
      }),
      spell: new EmbeddedDataField$3(SpellConfigurationData, { nullable: true, initial: null }),
      type: new StringField$A({ blank: false, nullable: true, initial: null })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    if ( "choices" in source ) Object.entries(source.choices).forEach(([k, c]) => {
      if ( foundry.utils.getType(c) === "number" ) source.choices[k] = { count: c };
    });
    if ( "pool" in source ) {
      source.pool = source.pool.map(i => foundry.utils.getType(i) === "string" ? { uuid: i } : i);
    }
    return source;
  }
}

/**
 * Data for a replacement.
 *
 * @typedef {object} ItemChoiceReplacement
 * @property {number} level        Level at which the original item was chosen.
 * @property {string} original     ID of the original item that was replaced.
 * @property {string} replacement  ID of the replacement item.
 */

/**
 * Value data for Item Choice advancement.
 *
 * @property {string} ability                                  Ability selected for the spells.
 * @property {Record<number, Record<string, string>>} added    Mapping of IDs to UUIDs for items added at each level.
 * @property {Record<number, ItemChoiceReplacement>} replaced  Information on items replaced at each level.
 */
class ItemChoiceValueData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      ability: new StringField$A(),
      added: new MappingField(new MappingField(new StringField$A())),
      replaced: new MappingField(new SchemaField$q({
        level: new NumberField$o({integer: true, min: 0}),
        original: new ForeignDocumentField$3(foundry.documents.BaseItem, {idOnly: true}),
        replacement: new ForeignDocumentField$3(foundry.documents.BaseItem, {idOnly: true})
      }))
    };
  }
}

/**
 * Configuration application for item grants.
 */
class ItemGrantConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["item-grant"],
    dropKeyPath: "items"
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    details: {
      template: "systems/dnd5e/templates/advancement/item-grant-config-details.hbs"
    },
    spellConfig: {
      template: "systems/dnd5e/templates/advancement/advancement-spell-config-section.hbs"
    },
    items: {
      template: "systems/dnd5e/templates/advancement/item-grant-config-items.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    context.items = this.advancement.configuration.items.map(data => ({
      data,
      fields: this.advancement.configuration.schema.fields.items.element.fields,
      index: fromUuidSync(data.uuid)
    }));

    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }));
    context.showContainerWarning = context.items.some(i => i.index?.type === "container");
    context.showSpellConfig = context.items.some(i => i.index?.type === "spell");
    context.showRequireSpellSlot = !this.advancement.configuration.spell?.preparation
      || CONFIG.DND5E.spellPreparationModes[this.advancement.configuration.spell?.preparation]?.upcast;

    return context;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  async prepareConfigurationUpdate(configuration) {
    if ( configuration.spell ) configuration.spell.ability ??= [];
    return configuration;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _validateDroppedItem(event, item) {
    this.advancement._validateItemType(item);
  }
}

const { ArrayField: ArrayField$c, BooleanField: BooleanField$i, EmbeddedDataField: EmbeddedDataField$2, SchemaField: SchemaField$p, StringField: StringField$z } = foundry.data.fields;

/**
 * Configuration data for an individual item provided by item grant.
 *
 * @typedef {object} ItemGrantItemConfiguration
 * @property {string} uuid       UUID of the item to grant.
 * @property {boolean} optional  Is this item optional? Has no effect if whole advancement is optional.
 */

/**
 * Configuration data for the Item Grant advancement.
 *
 * @property {ItemGrantItemConfiguration[]} items  Data for the items to be granted.
 * @property {boolean} optional                    Should user be able to de-select any individual option?
 * @property {SpellConfigurationData} spell        Data used to modify any granted spells.
 */
class ItemGrantConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ItemGrant", "DND5E.ADVANCEMENT.SPELLCONFIG"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      items: new ArrayField$c(new SchemaField$p({
        uuid: new StringField$z(),
        optional: new BooleanField$i()
      }), { required: true }),
      optional: new BooleanField$i({ required: true }),
      spell: new EmbeddedDataField$2(SpellConfigurationData, { required: true, nullable: true, initial: null })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    if ( "items" in source ) {
      source.items = source.items.map(i => foundry.utils.getType(i) === "string" ? { uuid: i } : i);
    }
    return source;
  }
}

/**
 * Advancement that automatically grants one or more items to the player. Presents the player with the option of
 * skipping any or all of the items.
 */
class ItemGrantAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: ItemGrantConfigurationData
      },
      order: 40,
      icon: "icons/sundries/books/book-open-purple.webp",
      typeIcon: "systems/dnd5e/icons/svg/item-grant.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.ItemGrant.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.ItemGrant.Hint"),
      apps: {
        config: ItemGrantConfig,
        flow: ItemGrantFlow
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * The item types that are supported in Item Grant.
   * @type {Set<string>}
   */
  static VALID_TYPES = new Set(["feat", "spell", "consumable", "container", "equipment", "loot", "tool", "weapon"]);

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  configuredForLevel(level) {
    return !foundry.utils.isEmpty(this.value);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    // Link to compendium items
    if ( !this.value.added || configMode ) return this.configuration.items.filter(i => fromUuidSync(i.uuid))
      .reduce((html, i) => html + dnd5e.utils.linkForUuid(i.uuid), "");

    // Link to items on the actor
    else {
      return Object.keys(this.value.added).map(id => {
        const item = this.actor.items.get(id);
        return item?.toAnchor({classes: ["content-link"]}).outerHTML ?? "";
      }).join("");
    }
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /**
   * Location where the added items are stored for the specified level.
   * @param {number} level  Level being advanced.
   * @returns {string}
   */
  storagePath(level) {
    return "value.added";
  }

  /* -------------------------------------------- */

  /**
   * Locally apply this advancement to the actor.
   * @param {number} level              Level being advanced.
   * @param {object} data               Data from the advancement form.
   * @param {object} [retainedData={}]  Item data grouped by UUID. If present, this data will be used rather than
   *                                    fetching new data from the source.
   * @returns {object}
   */
  async apply(level, data, retainedData={}) {
    const items = [];
    const updates = {};
    for ( const uuid of filteredKeys(data) ) {
      let itemData = retainedData[uuid];
      if ( !itemData ) {
        itemData = await this.createItemData(uuid);
        if ( !itemData ) continue;
      }
      if ( itemData.type === "spell" ) this.configuration.spell?.applySpellChanges(itemData, {
        ability: data.ability ?? this.retainedData?.ability ?? this.value?.ability
      });

      items.push(itemData);
      updates[itemData._id] = uuid;
    }
    if ( items.length ) {
      this.actor.updateSource({ items });
      this.updateSource({
        "value.ability": data.ability,
        [this.storagePath(level)]: updates
      });
    }
    return updates;
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    if ( this.configuration.optional
      || (this.configuration.spell?.ability?.size > 1)
      || this.configuration.items.some(i => i.optional) ) return false;
    return Object.fromEntries(this.configuration.items.map(({ uuid }) => [uuid, true]));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  restore(level, data) {
    const updates = {};
    for ( const item of data.items ) {
      this.actor.updateSource({items: [item]});
      updates[item._id] = item.flags.dnd5e.sourceId;
    }
    this.updateSource({
      "value.ability": data.ability,
      [this.storagePath(level)]: updates
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  reverse(level) {
    const items = [];
    const keyPath = this.storagePath(level);
    for ( const id of Object.keys(foundry.utils.getProperty(this, keyPath) ?? {}) ) {
      const item = this.actor.items.get(id);
      if ( item ) items.push(item.toObject());
      this.actor.items.delete(id);
    }
    this.updateSource({[keyPath.replace(/\.([\w\d]+)$/, ".-=$1")]: null});
    return { ability: this.value?.ability, items };
  }

  /* -------------------------------------------- */

  /**
   * Verify that the provided item can be used with this advancement based on the configuration.
   * @param {Item5e} item                   Item that needs to be tested.
   * @param {object} config
   * @param {boolean} [config.strict=true]  Should an error be thrown when an invalid type is encountered?
   * @returns {boolean}                     Is this type valid?
   * @throws {Error}                        An error if the item is invalid and strict is `true`.
   */
  _validateItemType(item, { strict=true }={}) {
    if ( !item ) return false;
    if ( this.constructor.VALID_TYPES.has(item.type) ) return true;
    const type = game.i18n.localize(CONFIG.Item.typeLabels[item.type]);
    if ( strict ) throw new Error(game.i18n.format("DND5E.AdvancementItemTypeInvalidWarning", {type}));
    return false;
  }
}

/**
 * Advancement that presents the player with a choice of multiple items that they can take. Keeps track of which
 * items were selected at which levels.
 */
class ItemChoiceAdvancement extends ItemGrantAdvancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: ItemChoiceConfigurationData,
        value: ItemChoiceValueData
      },
      order: 50,
      icon: "icons/magic/symbols/cog-orange-red.webp",
      typeIcon: "systems/dnd5e/icons/svg/item-choice.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Hint"),
      multiLevel: true,
      apps: {
        config: ItemChoiceConfig,
        flow: ItemChoiceFlow
      }
    });
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get levels() {
    return Array.from(Object.keys(this.configuration.choices));
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  configuredForLevel(level) {
    return (this.value.added?.[level] !== undefined) || !this.configuration.choices[level]?.count;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  titleForLevel(level, { configMode=false }={}) {
    const data = this.configuration.choices[level] ?? {};
    let tag;
    if ( data.count ) tag = game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Choose", { count: data.count });
    else if ( data.replacement ) tag = game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Replacement.Title");
    else return this.title;
    return `${this.title} <em>(${tag})</em>`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    const items = this.value.added?.[level];
    if ( !items || configMode ) return "";
    return Object.values(items).reduce((html, uuid) => html + game.dnd5e.utils.linkForUuid(uuid), "");
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @override */
  storagePath(level) {
    return `value.added.${level}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, { replace: original, ...data }, retainedData={}) {
    let replacement;
    if ( retainedData.replaced ) ({ original, replacement } = retainedData.replaced);

    const updates = await super.apply(level, data, retainedData);

    replacement ??= Object.keys(updates).pop();
    if ( original && replacement ) {
      const replacedLevel = Object.entries(this.value.added).reverse().reduce((level, [l, v]) => {
        if ( (original in v) && (Number(l) > level) ) return Number(l);
        return level;
      }, 0);
      if ( Number.isFinite(replacedLevel) ) {
        this.actor.items.delete(original);
        this.updateSource({ [`value.replaced.${level}`]: { level: replacedLevel, original, replacement } });
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    return false;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  restore(level, data) {
    const original = this.actor.items.get(data.replaced?.original);
    if ( data.replaced && !original ) data.items = data.items.filter(i => i._id !== data.replaced.replacement);

    super.restore(level, data);

    if ( data.replaced ) {
      if ( !original ) {
        throw new ItemChoiceAdvancement.ERROR(game.i18n.localize("DND5E.ADVANCEMENT.ItemChoice.Warning.NoOriginal"));
      }
      this.actor.items.delete(data.replaced.original);
      this.updateSource({ [`value.replaced.${level}`]: data.replaced });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async reverse(level) {
    const retainedData = await super.reverse(level);

    const replaced = retainedData.replaced = this.value.replaced[level];
    if ( replaced ) {
      const uuid = this.value.added[replaced.level][replaced.original];
      const itemData = await this.createItemData(uuid, replaced.original);
      if ( itemData ) {
        if ( itemData.type === "spell" ) {
          foundry.utils.mergeObject(itemData, this.configuration.spell?.spellChanges ?? {});
        }
        this.actor.updateSource({ items: [itemData] });
        this.updateSource({ [`value.replaced.-=${level}`]: null });
      }
    }

    return retainedData;
  }

  /* -------------------------------------------- */

  /**
   * Verify that the provided item can be used with this advancement based on the configuration.
   * @param {Item5e} item                   Item that needs to be tested.
   * @param {object} config
   * @param {string} config.type            Type restriction on this advancement.
   * @param {object} config.restriction     Additional restrictions to be applied.
   * @param {boolean} [config.strict=true]  Should an error be thrown when an invalid type is encountered?
   * @returns {boolean}                     Is this type valid?
   * @throws {Error}                        An error if the item is invalid and strict is `true`.
   */
  _validateItemType(item, { type, restriction, strict=true }={}) {
    if ( !item ) return false;
    super._validateItemType(item, { strict });
    type ??= this.configuration.type;
    restriction ??= this.configuration.restriction;

    // Type restriction is set and the item type does not match the selected type
    if ( type && (type !== item.type) ) {
      type = game.i18n.localize(CONFIG.Item.typeLabels[restriction]);
      if ( strict ) {
        throw new Error(game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Warning.InvalidType", { type: typeLabel }));
      }
      return false;
    }

    // If additional type restrictions applied, make sure they are valid
    if ( (type === "feat") && restriction.type ) {
      const typeConfig = CONFIG.DND5E.featureTypes[restriction.type];
      const subtype = typeConfig.subtypes?.[restriction.subtype];
      let errorLabel;
      if ( restriction.type !== item.system.type.value ) errorLabel = typeConfig.label;
      else if ( subtype && (restriction.subtype !== item.system.type.subtype) ) errorLabel = subtype;
      if ( errorLabel ) {
        if ( strict ) {
          throw new Error(game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Warning.InvalidType", { type: errorLabel }));
        }
        return false;
      }
    }

    // If spell level is restricted, ensure the spell is of the appropriate level
    const l = parseInt(restriction.level);
    if ( (type === "spell") && !Number.isNaN(l) && (item.system.level !== l) ) {
      const level = CONFIG.DND5E.spellLevels[l];
      if ( strict ) {
        throw new Error(game.i18n.format("DND5E.ADVANCEMENT.ItemChoice.Warning.SpellLevelSpecific", { level }));
      }
      return false;
    }

    return true;
  }
}

/**
 * Create a checkbox input for a BooleanField.
 * @param {BooleanField} field               The field.
 * @param {FormInputConfig<boolean>} config  The input configuration.
 * @returns {HTMLElement}
 */
function createCheckboxInput(field, config) {
  const input = document.createElement("dnd5e-checkbox");
  input.name = config.name;
  if ( config.value ) input.checked = true;
  foundry.applications.fields.setInputAttributes(input, config);
  if ( "ariaLabel" in config ) input.ariaLabel = config.ariaLabel;
  if ( "classes" in config ) input.className = config.classes;
  return input;
}

/* -------------------------------------------- */

/**
 * Create a grid of checkboxes.
 * @param {DataField} field         The field.
 * @param {FormInputConfig} config  The input configuration.
 * @returns {HTMLCollection}
 */
function createMultiCheckboxInput(field, config) {
  const template = document.createElement("template");
  for ( const option of config.options || [] ) {
    const { label, value, selected } = option;
    const element = document.createElement("label");
    element.classList.add("checkbox");
    element.innerHTML = `
      <dnd5e-checkbox name="${config.name}.${value}" ${selected ? "checked" : ""}></dnd5e-checkbox>
      <span>${label}</span>
    `;
    template.content.append(element);
  }
  return template.content.children;
}

/* -------------------------------------------- */

/**
 * Create a number input for a NumberField.
 * @param {NumberField} field               The field.
 * @param {FormInputConfig<number>} config  The input configuration.
 * @returns {HTMLElement|HTMLCollection}
 */
function createNumberInput(field, config) {
  delete config.input;
  const input = field.toInput(config);
  if ( "ariaLabel" in config ) input.ariaLabel = config.ariaLabel;
  if ( "classes" in config ) input.className = config.classes;
  return input;
}

/* -------------------------------------------- */

/**
 * Create a text input for a StringField.
 * @param {StringField} field               The field.
 * @param {FormInputConfig<string>} config  The input configuration.
 * @returns {HTMLElement|HTMLCollection}
 */
function createTextInput(field, config) {
  delete config.input;
  const input = field.toInput(config);
  if ( "classes" in config ) input.className = config.classes;
  return input;
}

var fields = /*#__PURE__*/Object.freeze({
  __proto__: null,
  createCheckboxInput: createCheckboxInput,
  createMultiCheckboxInput: createMultiCheckboxInput,
  createNumberInput: createNumberInput,
  createTextInput: createTextInput
});

const { BooleanField: BooleanField$h, NumberField: NumberField$n, SchemaField: SchemaField$o, SetField: SetField$h, StringField: StringField$y } = foundry.data.fields;

/**
 * Data model for the Scale Value advancement type.
 *
 * @property {string} identifier        Identifier used to select this scale value in roll formulas.
 * @property {string} type              Type of data represented by this scale value.
 * @property {object} [distance]
 * @property {string} [distance.units]  If distance type is selected, the units each value uses.
 * @property {Object<string, *>} scale  Scale values for each level. Value format is determined by type.
 */
class ScaleValueConfigurationData extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      identifier: new IdentifierField({ required: true }),
      type: new StringField$y({ required: true, initial: "string", choices: TYPES }),
      distance: new SchemaField$o({ units: new StringField$y({ required: true }) }),
      scale: new MappingField(new ScaleValueEntryField(), { required: true })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    super.migrateData(source);
    if ( source.type === "numeric" ) source.type = "number";
    Object.values(source.scale ?? {}).forEach(v => TYPES[source.type].migrateData(v));
  }
}


/**
 * Data field that automatically selects the appropriate ScaleValueType based on the selected type.
 */
class ScaleValueEntryField extends foundry.data.fields.ObjectField {
  /** @override */
  _cleanType(value, options) {
    if ( !(typeof value === "object") ) value = {};

    // Use a defined DataModel
    const cls = TYPES[options.source?.type];
    if ( cls ) return cls.cleanData(value, options);

    return value;
  }

  /* -------------------------------------------- */

  /** @override */
  initialize(value, model, options={}) {
    const cls = TYPES[model.type];
    if ( !value || !cls ) return value;
    return new cls(value, {parent: model, ...options});
  }

  /* -------------------------------------------- */

  /** @override */
  toObject(value) {
    return value.toObject(false);
  }
}


/**
 * Base scale value data type that stores generic string values.
 *
 * @property {string} value  String value.
 */
class ScaleValueType extends foundry.abstract.DataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue.Type.String"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      value: new StringField$y({ required: true })
    };
  }

  /* -------------------------------------------- */

  /**
   * Information on how a scale value of this type is configured.
   *
   * @typedef {object} ScaleValueTypeMetadata
   * @property {string} label       Name of this type.
   * @property {string} hint        Hint for this type shown in the scale value configuration.
   * @property {string} identifier  Hint for the identifier for this type.
   * @property {boolean} isNumeric  When using the default editing interface, should numeric inputs be used?
   */

  /**
   * Configuration information for this scale value type.
   * @type {ScaleValueTypeMetadata}
   */
  static get metadata() {
    return {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.String.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.String.Hint",
      identifier: "DND5E.ADVANCEMENT.ScaleValue.Type.String.Identifier",
      isNumeric: false
    };
  }

  /* -------------------------------------------- */

  /**
   * Attempt to convert another scale value type to this one.
   * @param {ScaleValueType} original  Original type to attempt to convert.
   * @param {object} [options]         Options which affect DataModel construction.
   * @returns {ScaleValueType|null}
   */
  static convertFrom(original, options) {
    return new this({ value: original.formula }, options);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * This scale value prepared to be used in roll formulas.
   * @type {string|null}
   */
  get formula() { return this.value; }

  /* -------------------------------------------- */

  /**
   * This scale value formatted for display.
   * @type {string|null}
   */
  get display() { return this.formula; }

  /* -------------------------------------------- */

  /**
   * Shortcut to the prepared value when used in roll formulas.
   * @returns {string}
   */
  toString() {
    return this.formula;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve field data with associated values.
   * @param {number} level                Level for which this data is being prepared.
   * @param {ScaleValueType} [value]      Value for the field at this level.
   * @param {ScaleValueType} [lastValue]  Previous value used to generate placeholders.
   * @returns {Record<string, object>}
   */
  static getFields(level, value, lastValue) {
    const fields = {};
    for ( const [name, field] of Object.entries(this.schema.fields) ) {
      if ( field.options.hidden ) continue;
      fields[name] = {
        field,
        input: field instanceof BooleanField$h ? createCheckboxInput : null,
        name: `configuration.scale.${level}.${name}`,
        placeholder: this.getPlaceholder(name, lastValue),
        value: value?.[name]
      };
    }
    return fields;
  }

  /* -------------------------------------------- */

  /**
   * Create a placeholder value for the provided field.
   * @param {string} name                 Name of the field.
   * @param {ScaleValueType} [lastValue]  Scale value from a lower level.
   * @returns {string}
   */
  static getPlaceholder(name, lastValue) {
    return lastValue?.[name] ?? "";
  }
}


/**
 * Scale value data type that stores numeric values.
 *
 * @property {number} value  Numeric value.
 */
class ScaleValueTypeNumber extends ScaleValueType {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      value: new NumberField$n({ required: true })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.Number.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.Number.Hint",
      isNumeric: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static convertFrom(original, options) {
    const value = Number(original.formula);
    if ( Number.isNaN(value) ) return null;
    return new this({value}, options);
  }
}


/**
 * Scale value data type that stores challenge ratings.
 *
 * @property {number} value  CR value.
 */
class ScaleValueTypeCR extends ScaleValueTypeNumber {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue.Type.CR"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      value: new NumberField$n({ required: true, min: 0 })
      // TODO: Add CR validator
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.CR.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.CR.Hint"
    });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get display() {
    switch ( this.value ) {
      case 0.125: return "&frac18;";
      case 0.25: return "&frac14;";
      case 0.5: return "&frac12;";
      default: return super.display;
    }
  }
}


/**
 * Scale value data type that stores dice values.
 *
 * @property {number} number  Number of dice.
 * @property {number} faces   Die faces.
 */
class ScaleValueTypeDice extends ScaleValueType {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.ADVANCEMENT.ScaleValue.Type.Dice"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      number: new NumberField$n({ nullable: true, integer: true }),
      faces: new NumberField$n({ required: true, integer: true }),
      modifiers: new SetField$h(new StringField$y({ required: true }), { hidden: true })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.Dice.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.Dice.Hint",
      identifier: "DND5E.ADVANCEMENT.ScaleValue.Type.Dice.Identifier"
    });
  }

  /* -------------------------------------------- */

  /**
   * List of die faces that can be chosen.
   * @type {number[]}
   */
  static FACES = [2, 3, 4, 6, 8, 10, 12, 20, 100];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static convertFrom(original, options) {
    const [number, faces] = (original.formula ?? "").split("d");
    if ( !faces || !Number.isNumeric(number) || !Number.isNumeric(faces) ) return null;
    return new this({number: Number(number) || null, faces: Number(faces)}, options);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get formula() {
    if ( !this.faces ) return null;
    return `${this.number ?? ""}${this.die}`;
  }

  /* -------------------------------------------- */

  /**
   * The entire die, with leading "d" and any modifiers, e.g., "d4" or "d4r1".
   * @type {string}
   */
  get die() {
    if ( !this.faces ) return "";
    return `d${this.faces}${this.mods}`;
  }

  /* -------------------------------------------- */

  /**
   * The die modifiers.
   * @type {string}
   */
  get mods() {
    if ( !this.modifiers ) return "";
    return this.modifiers.reduce((acc, mod) => {
      return acc + (dnd5e.utils.isValidDieModifier(mod) ? mod : "");
    }, "");
  }

  /* -------------------------------------------- */

  /**
   * The die value to be rolled with the leading "d" (e.g. "d4").
   * @type {string}
   */
  get denom() {
    if ( !this.faces ) return "";
    return `d${this.faces}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    if ( source.n ) source.number = source.n;
    if ( source.die ) source.faces = source.die;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static getFields(level, value, lastValue) {
    const fields = super.getFields(level, value, lastValue);
    fields.faces.options = [
      { value: "", label: fields.faces.placeholder },
      { rule: true },
      ...this.FACES.map(value => ({ value, label: `d${value}` }))
    ];
    return fields;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getPlaceholder(name, lastValue) {
    if ( (name === "faces") && lastValue?.faces ) return `d${lastValue.faces}`;
    return super.getPlaceholder(name, lastValue);
  }
}


/**
 * Scale value data type that stores distance values.
 *
 * @property {number} value  Numeric value.
 */
class ScaleValueTypeDistance extends ScaleValueTypeNumber {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      label: "DND5E.ADVANCEMENT.ScaleValue.Type.Distance.Label",
      hint: "DND5E.ADVANCEMENT.ScaleValue.Type.Distance.Hint"
    });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get display() {
    return formatLength(this.value, this.parent.configuration.distance?.units || "ft");
  }
}


/**
 * The available types of scaling value.
 * @enum {ScaleValueType}
 */
const TYPES = {
  string: ScaleValueType,
  number: ScaleValueTypeNumber,
  cr: ScaleValueTypeCR,
  dice: ScaleValueTypeDice,
  distance: ScaleValueTypeDistance
};

var scaleValue = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ScaleValueConfigurationData: ScaleValueConfigurationData,
  ScaleValueEntryField: ScaleValueEntryField,
  ScaleValueType: ScaleValueType,
  ScaleValueTypeCR: ScaleValueTypeCR,
  ScaleValueTypeDice: ScaleValueTypeDice,
  ScaleValueTypeDistance: ScaleValueTypeDistance,
  ScaleValueTypeNumber: ScaleValueTypeNumber,
  TYPES: TYPES
});

/**
 * Configuration application for scale values.
 */
class ScaleValueConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["scale-value"],
    position: {
      width: 540
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    config: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/advancement-controls-section.hbs"
    },
    details: {
      container: { classes: ["column-container"], id: "column-left" },
      template: "systems/dnd5e/templates/advancement/scale-value-config-details.hbs"
    },
    levels: {
      container: { classes: ["column-container"], id: "column-right" },
      template: "systems/dnd5e/templates/advancement/scale-value-config-levels.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const config = this.advancement.configuration;
    const type = TYPES[config.type];

    context.distanceOptions = Object.entries(CONFIG.DND5E.movementUnits).map(([value, label]) => ({ value, label }));
    context.identifier = {
      placeholder: config.identifier || this.advancement.title?.slugify()
        || this.advancement.constructor.metadata.title.slugify()
    };
    context.identifier.hint = game.i18n.format(type.metadata.identifier, {
      class: this.item.identifier, identifier: context.identifier.placeholder
    });
    context.levels = this._prepareLevelData();
    context.type = {
      ...type.metadata,
      fields: type.schema.fields,
      options: Object.entries(TYPES).map(([value, d]) => ({ value, label: game.i18n.localize(d.metadata.label )}))
    };

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data to display at each of the scale levels.
   * @returns {object}
   * @protected
   */
  _prepareLevelData() {
    let lastValue = null;
    let levels = Array.fromRange(CONFIG.DND5E.maxLevel + 1);
    if ( ["class", "subclass"].includes(this.advancement.item.type) ) levels = levels.slice(1);
    return levels.reduce((obj, level) => {
      const value = this.advancement.configuration.scale[level]?.clone();
      obj[level] = {
        fields: TYPES[this.advancement.configuration.type].getFields(level, value, lastValue),
        value: null
      };
      if ( value ) {
        this._mergeScaleValues(value, lastValue);
        obj[level].className = "new-scale-value";
        obj[level].value = value;
        lastValue = value;
      }
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * For scale values with multiple properties, have missing properties inherit from earlier filled-in values.
   * @param {*} value      The primary value.
   * @param {*} lastValue  The previous value.
   */
  _mergeScaleValues(value, lastValue) {
    for ( const k of Object.keys(lastValue ?? {}) ) {
      if ( value[k] == null ) value[k] = lastValue[k];
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    this.form.querySelector("input[name='title']").addEventListener("input", this._onChangeTitle.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * If no identifier is manually entered, slugify the custom title and display as placeholder.
   * @param {Event} event  Change event to the title input.
   */
  _onChangeTitle(event) {
    const slug = (event.target.value || this.advancement.constructor.metadata.title).slugify();
    this.form.querySelector("input[name='configuration.identifier']").placeholder = slug;
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareConfigurationUpdate(configuration) {
    // Ensure multiple values in a row are not the same
    let lastValue = null;
    for ( const [lvl, value] of Object.entries(configuration.scale) ) {
      if ( this.advancement.testEquality(lastValue, value) ) configuration.scale[lvl] = null;
      else if ( Object.keys(value ?? {}).some(k => value[k]) ) {
        this._mergeScaleValues(value, lastValue);
        lastValue = value;
      }
    }
    configuration.scale = this.constructor._cleanedObject(configuration.scale);
    return configuration;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, submitData) {
    const typeChange = foundry.utils.hasProperty(submitData, "configuration.type");
    if ( typeChange && (submitData.configuration.type !== this.advancement.configuration.type) ) {
      // Clear existing scale value data to prevent error during type update
      await this.advancement.update(Array.fromRange(CONFIG.DND5E.maxLevel, 1).reduce((obj, lvl) => {
        obj[`configuration.scale.-=${lvl}`] = null;
        return obj;
      }, {}));
      submitData.configuration.scale ??= {};
      const OriginalType = TYPES[this.advancement.configuration.type];
      const NewType = TYPES[submitData.configuration.type];
      for ( const [lvl, data] of Object.entries(submitData.configuration.scale) ) {
        const original = new OriginalType(data, { parent: this.advancement, strict: false });
        submitData.configuration.scale[lvl] = NewType.convertFrom(original)?.toObject();
      }
    }
    return super._processSubmitData(event, submitData);
  }

  /* -------------------------------------------- */

  /** @override */
  static _cleanedObject(object) {
    return Object.entries(object).reduce((obj, [key, value]) => {
      if ( Object.keys(value ?? {}).some(k => value[k]) ) obj[key] = value;
      else obj[`-=${key}`] = null;
      return obj;
    }, {});
  }
}

/**
 * Inline application that displays any changes to a scale value.
 */
class ScaleValueFlow extends AdvancementFlow {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/scale-value-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData() {
    return foundry.utils.mergeObject(super.getData(), {
      initial: this.advancement.valueForLevel(this.level - 1)?.display,
      final: this.advancement.valueForLevel(this.level).display
    });
  }
}

/**
 * Advancement that represents a value that scales with class level. **Can only be added to classes or subclasses.**
 */
class ScaleValueAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: ScaleValueConfigurationData
      },
      order: 60,
      icon: "icons/sundries/gaming/dice-pair-white-green.webp",
      typeIcon: "systems/dnd5e/icons/svg/scale-value.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.ScaleValue.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.ScaleValue.Hint"),
      multiLevel: true,
      apps: {
        config: ScaleValueConfig,
        flow: ScaleValueFlow
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * The available types of scaling value.
   * @enum {ScaleValueType}
   */
  static TYPES = TYPES;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static localize() {
    super.localize();
    Object.values(TYPES).forEach(v => Localization.localizeDataModel(v));
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get levels() {
    return Array.from(Object.keys(this.configuration.scale).map(l => Number(l)))
      .filter(l => !["class", "subclass"].includes(this.item.type) ? true : l !== 0);
  }

  /* -------------------------------------------- */

  /**
   * Identifier for this scale value, either manual value or the slugified title.
   * @type {string}
   */
  get identifier() {
    return this.configuration.identifier || this.title.slugify();
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  titleForLevel(level, { configMode=false, legacyDisplay=false }={}) {
    const value = this.valueForLevel(level)?.display;
    if ( !value || !legacyDisplay ) return this.title;
    return `${this.title}: <strong>${value}</strong>`;
  }

  /* -------------------------------------------- */

  /**
   * Scale value for the given level.
   * @param {number} level      Level for which to get the scale value.
   * @returns {ScaleValueType}  Scale value at the given level or null if none exists.
   */
  valueForLevel(level) {
    const key = Object.keys(this.configuration.scale).reverse().find(l => Number(l) <= level);
    const data = this.configuration.scale[key];
    const TypeClass = this.constructor.TYPES[this.configuration.type];
    if ( !data || !TypeClass ) return null;
    return new TypeClass(data, { parent: this });
  }

  /* -------------------------------------------- */

  /**
   * Compare two scaling values and determine if they are equal.
   * @param {*} a
   * @param {*} b
   * @returns {boolean}
   */
  testEquality(a, b) {
    const keys = Object.keys(a ?? {});
    if ( keys.length !== Object.keys(b ?? {}).length ) return false;
    for ( const k of keys ) {
      if ( a[k] !== b[k] ) return false;
    }
    return true;
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    return {};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc*/
  getContextMenuOptions() {
    const options = super.getContextMenuOptions();
    options.push({
      name: "DND5E.ADVANCEMENT.ScaleValue.Action.CopyFormula",
      icon: '<i class="fa-solid fa-copy"></i>',
      callback: () => {
        const value = `@scale.${this.item.identifier}.${this.identifier}`;
        game.clipboard.copyPlainText(value);
        ui.notifications.info(game.i18n.format("DND5E.Copied", { value }), { console: false });
      },
      group: "copy"
    });
    return options;
  }
}

const { BooleanField: BooleanField$g } = foundry.data.fields;

/**
 * Configuration application for size advancement.
 */
class SizeConfig extends AdvancementConfig$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["size"]
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    details: {
      template: "systems/dnd5e/templates/advancement/size-config-details.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    return foundry.utils.mergeObject(context, {
      default: {
        hint: this.advancement.automaticHint
      },
      showLevelSelector: false,
      sizes: Object.entries(CONFIG.DND5E.actorSizes).reduce((obj, [key, { label }]) => {
        obj[key] = {
          field: new BooleanField$g({ label }),
          input: context.inputs.createCheckboxInput,
          value: this.advancement.configuration.sizes.has(key)
        };
        return obj;
      }, {})
    });
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async prepareConfigurationUpdate(configuration) {
    configuration.sizes = filteredKeys(configuration.sizes ?? {});
    return configuration;
  }
}

/**
 * Inline application that displays size advancement.
 */
class SizeFlow extends AdvancementFlow {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/advancement/size-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData() {
    const sizes = this.advancement.configuration.sizes;
    return foundry.utils.mergeObject(super.getData(), {
      singleSize: sizes.size === 1 ? sizes.first() : null,
      hint: this.advancement.hint || this.advancement.automaticHint,
      selectedSize: this.retainedData?.size ?? this.advancement.value.size,
      sizes: Array.from(sizes).reduce((obj, key) => {
        obj[key] = CONFIG.DND5E.actorSizes[key].label;
        return obj;
      }, {})
    });
  }
}

const { SetField: SetField$g, StringField: StringField$x } = foundry.data.fields;

/**
 * Configuration data for the size advancement type.
 */
class SizeConfigurationData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      sizes: new SetField$g(new StringField$x(), { required: false, initial: ["med"], label: "DND5E.Size" })
    };
  }

  /* -------------------------------------------- */

  get hint() {
    foundry.utils.logCompatibilityWarning(
      "Advancement hints are now part of the base data model.",
      { since: "DnD5e 3.3", until: "DnD5e 4.1" }
    );
    return this.parent.hint ?? "";
  }
}

/**
 * Value data for the size advancement type.
 */
class SizeValueData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      size: new StringField$x({ required: false, label: "DND5E.Size" })
    };
  }
}

/**
 * Advancement that handles player size.
 */
class SizeAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        configuration: SizeConfigurationData,
        value: SizeValueData
      },
      order: 25,
      icon: "icons/environment/wilderness/tree-ash.webp",
      typeIcon: "systems/dnd5e/icons/svg/size.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.Size.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.Size.Hint"),
      apps: {
        config: SizeConfig,
        flow: SizeFlow
      }
    });
  }

  /* -------------------------------------------- */
  /*  Instance Properties                         */
  /* -------------------------------------------- */

  /**
   * Hint that will be displayed to players if none is entered.
   * @type {string}
   */
  get automaticHint() {
    if ( !this.configuration.sizes.size ) return "";
    if ( this.configuration.sizes.size === 1 ) return game.i18n.format("DND5E.ADVANCEMENT.Size.DefaultHint.Single", {
      size: CONFIG.DND5E.actorSizes[this.configuration.sizes.first()].label
    });

    const listFormatter = new Intl.ListFormat(game.i18n.lang, { type: "disjunction" });
    return game.i18n.format("DND5E.ADVANCEMENT.Size.DefaultHint.Multiple", {
      sizes: listFormatter.format(this.configuration.sizes.map(s => CONFIG.DND5E.actorSizes[s].label))
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get levels() {
    return [0];
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  summaryForLevel(level, { configMode=false }={}) {
    const sizes = configMode ? Array.from(this.configuration.sizes) : this.value.size ? [this.value.size] : [];
    return sizes.map(s => `<span class="tag">${CONFIG.DND5E.actorSizes[s].label}</span>`).join("");
  }

  /* -------------------------------------------- */
  /*  Editing Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static availableForItem(item) {
    return !item.advancement.byType.Size?.length;
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async apply(level, data) {
    this.actor.updateSource({ "system.traits.size": data.size ?? this.configuration.sizes.first() ?? "med" });
    this.updateSource({ value: data });
  }

  /* -------------------------------------------- */

  /** @override */
  automaticApplicationValue(level) {
    if ( this.configuration.sizes > 1 ) return false;
    return this.configuration.sizes.first() ?? "med";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async restore(level, data) {
    this.apply(level, data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async reverse(level) {
    this.actor.updateSource({"system.traits.size": "med"});
    this.updateSource({ "value.size": null });
  }
}

/**
 * Inline application that presents the player with a choice of subclass.
 */
class SubclassFlow extends AdvancementFlow {

  /**
   * Cached subclass dropped onto the advancement.
   * @type {Item5e|false}
   */
  subclass;

  /* -------------------------------------------- */

  /** @inheritdoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      dragDrop: [{ dropSelector: "form" }],
      template: "systems/dnd5e/templates/advancement/subclass-flow.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async retainData(data) {
    await super.retainData(data);
    const uuid = foundry.utils.getProperty(data, "flags.dnd5e.sourceId");
    if ( uuid ) this.subclass = await fromUuid(uuid);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async getData() {
    const context = await super.getData();
    context.subclass = this.subclass;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  activateListeners(jQuery) {
    super.activateListeners(jQuery);
    const [html] = jQuery;
    html.querySelector('[data-action="browse"]')?.addEventListener("click", this._onBrowseCompendium.bind(this));
    html.querySelector('[data-action="delete"]')?.addEventListener("click", this._onItemDelete.bind(this));
    html.querySelector("[data-action='viewItem']")?.addEventListener("click", this._onClickFeature.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the compendium browser and displaying the result.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onBrowseCompendium(event) {
    event.preventDefault();
    const filters = {
      locked: {
        additional: { class: { [this.item.identifier]: 1 } },
        types: new Set(["subclass"])
      }
    };
    const result = await CompendiumBrowser.selectOne({ filters });
    if ( result ) this.subclass = await fromUuid(result);
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking on a feature during item grant to preview the feature.
   * @param {MouseEvent} event  The triggering event.
   * @protected
   */
  async _onClickFeature(event) {
    event.preventDefault();
    const uuid = event.target.closest("[data-uuid]")?.dataset.uuid;
    const item = await fromUuid(uuid);
    item?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a dropped item.
   * @param {Event} event  The originating click event.
   * @protected
   */
  async _onItemDelete(event) {
    event.preventDefault();
    this.subclass = false;
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _onDrop(event) {
    // Try to extract the data
    let data;
    try {
      data = JSON.parse(event.dataTransfer.getData("text/plain"));
    } catch(err) {
      return false;
    }

    if ( data.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);

    // Ensure the dropped item is a subclass
    if ( item.type !== "subclass" ) {
      ui.notifications.warn("DND5E.ADVANCEMENT.Subclass.Warning.InvalidType", { localize: true });
      return;
    }

    this.subclass = item;
    this.render();
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async _updateObject(event, formData) {
    if ( this.subclass ) await this.advancement.apply(this.level, { uuid: this.subclass.uuid }, this.retainedData);
  }
}

const { DocumentUUIDField: DocumentUUIDField$1 } = foundry.data.fields;

/**
 * Value data for Subclass advancement.
 * @property {Item5e} document  Copy of the subclass on the actor.
 * @property {string} uuid      UUID of the remote subclass source.
 */
class SubclassValueData extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      document: new LocalDocumentField(foundry.documents.BaseItem),
      uuid: new DocumentUUIDField$1()
    };
  }
}

/**
 * Advancement that allows the player to select a subclass for their class. Only allowed on class items
 * and can only be taken once.
 */
class SubclassAdvancement extends Advancement {

  /** @inheritDoc */
  static get metadata() {
    return foundry.utils.mergeObject(super.metadata, {
      dataModels: {
        value: SubclassValueData
      },
      order: 70,
      icon: "icons/skills/trades/mining-pickaxe-yellow-blue.webp",
      typeIcon: "systems/dnd5e/icons/svg/subclass.svg",
      title: game.i18n.localize("DND5E.ADVANCEMENT.Subclass.Title"),
      hint: game.i18n.localize("DND5E.ADVANCEMENT.Subclass.Hint"),
      apps: {
        flow: SubclassFlow
      }
    });
  }

  /* -------------------------------------------- */
  /*  Display Methods                             */
  /* -------------------------------------------- */

  /** @inheritdoc */
  configuredForLevel(level) {
    return !foundry.utils.isEmpty(this.value);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  summaryforLevel(level, { configMode=false }={}) {
    const subclass = this.item.subclass;
    if ( configMode || !subclass ) return "";
    return subclass.toAnchor().outerHTML;
  }

  /* -------------------------------------------- */
  /*  Editing Methods                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static availableForItem(item) {
    return !item.advancement.byType.Subclass?.length;
  }

  /* -------------------------------------------- */
  /*  Application Methods                         */
  /* -------------------------------------------- */

  /** @inheritdoc */
  async apply(level, data, retainedData) {
    const useRetained = data.uuid === foundry.utils.getProperty(retainedData, "flags.dnd5e.sourceId");
    let itemData = useRetained ? retainedData : null;
    if ( !itemData ) {
      itemData = await this.createItemData(data.uuid);
      foundry.utils.setProperty(itemData, "system.classIdentifier", this.item.identifier);
    }
    if ( itemData ) {
      this.actor.updateSource({ items: [itemData] });
      this.updateSource({ value: { document: itemData._id, uuid: data.uuid } });
    }
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async restore(level, data) {
    if ( !data ) return;
    this.actor.updateSource({ items: [data] });
    this.updateSource({ value: { document: data._id, uuid: data.flags.dnd5e.sourceId } });
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  async reverse(level) {
    const item = this.value.document ?? this.item.subclass;
    if ( !item ) return;
    this.actor.items.delete(item.id);
    this.updateSource({ value: { document: null, uuid: null } });
    return item.toObject();
  }
}

var _module$q = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityScoreImprovementAdvancement: AbilityScoreImprovementAdvancement,
  Advancement: Advancement,
  HitPointsAdvancement: HitPointsAdvancement,
  ItemChoiceAdvancement: ItemChoiceAdvancement,
  ItemGrantAdvancement: ItemGrantAdvancement,
  ScaleValueAdvancement: ScaleValueAdvancement,
  SizeAdvancement: SizeAdvancement,
  SubclassAdvancement: SubclassAdvancement,
  TraitAdvancement: TraitAdvancement
});

// Namespace Configuration Values
const DND5E = {};

// ASCII Artwork
DND5E.ASCII = `_______________________________
______      ______ _____ _____
|  _  \\___  |  _  \\  ___|  ___|
| | | ( _ ) | | | |___ \\| |__
| | | / _ \\/\\ | | |   \\ \\  __|
| |/ / (_>  < |/ //\\__/ / |___
|___/ \\___/\\/___/ \\____/\\____/
_______________________________`;

/**
 * Configuration data for abilities.
 *
 * @typedef {object} AbilityConfiguration
 * @property {string} label                               Localized label.
 * @property {string} abbreviation                        Localized abbreviation.
 * @property {string} fullKey                             Fully written key used as alternate for enrichers.
 * @property {string} [reference]                         Reference to a rule page describing this ability.
 * @property {string} [type]                              Whether this is a "physical" or "mental" ability.
 * @property {Object<string, number|string>}  [defaults]  Default values for this ability based on actor type.
 *                                                        If a string is used, the system will attempt to fetch.
 *                                                        the value of the specified ability.
 * @property {string} [icon]                              An SVG icon that represents the ability.
 */

/**
 * The set of Ability Scores used within the system.
 * @enum {AbilityConfiguration}
 */
DND5E.abilities = {
  str: {
    label: "DND5E.AbilityStr",
    abbreviation: "DND5E.AbilityStrAbbr",
    type: "physical",
    fullKey: "strength",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.nUPv6C66Ur64BIUH",
    icon: "systems/dnd5e/icons/svg/abilities/strength.svg"
  },
  dex: {
    label: "DND5E.AbilityDex",
    abbreviation: "DND5E.AbilityDexAbbr",
    type: "physical",
    fullKey: "dexterity",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ER8CKDUWLsFXuARJ",
    icon: "systems/dnd5e/icons/svg/abilities/dexterity.svg"
  },
  con: {
    label: "DND5E.AbilityCon",
    abbreviation: "DND5E.AbilityConAbbr",
    type: "physical",
    fullKey: "constitution",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MpA4jnwD17Q0RPg7",
    icon: "systems/dnd5e/icons/svg/abilities/constitution.svg"
  },
  int: {
    label: "DND5E.AbilityInt",
    abbreviation: "DND5E.AbilityIntAbbr",
    type: "mental",
    fullKey: "intelligence",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.WzWWcTIppki35YvF",
    icon: "systems/dnd5e/icons/svg/abilities/intelligence.svg",
    defaults: { vehicle: 0 }
  },
  wis: {
    label: "DND5E.AbilityWis",
    abbreviation: "DND5E.AbilityWisAbbr",
    type: "mental",
    fullKey: "wisdom",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.v3IPyTtqvXqN934s",
    icon: "systems/dnd5e/icons/svg/abilities/wisdom.svg",
    defaults: { vehicle: 0 }
  },
  cha: {
    label: "DND5E.AbilityCha",
    abbreviation: "DND5E.AbilityChaAbbr",
    type: "mental",
    fullKey: "charisma",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9FyghudYFV5QJOuG",
    icon: "systems/dnd5e/icons/svg/abilities/charisma.svg",
    defaults: { vehicle: 0 }
  },
  hon: {
    label: "DND5E.AbilityHon",
    abbreviation: "DND5E.AbilityHonAbbr",
    type: "mental",
    fullKey: "honor",
    defaults: { npc: "cha", vehicle: 0 },
    improvement: false
  },
  san: {
    label: "DND5E.AbilitySan",
    abbreviation: "DND5E.AbilitySanAbbr",
    type: "mental",
    fullKey: "sanity",
    defaults: { npc: "wis", vehicle: 0 },
    improvement: false
  }
};
preLocalize("abilities", { keys: ["label", "abbreviation"] });

/**
 * Configure which ability score is used as the default modifier for initiative rolls,
 * when calculating hit points per level and hit dice, and as the default modifier for
 * saving throws to maintain concentration.
 * @enum {string}
 */
DND5E.defaultAbilities = {
  meleeAttack: "str",
  rangedAttack: "dex",
  initiative: "dex",
  hitPoints: "con",
  concentration: "con"
};

/* -------------------------------------------- */

/**
 * Configuration data for skills.
 *
 * @typedef {object} SkillConfiguration
 * @property {string} label        Localized label.
 * @property {string} ability      Key for the default ability used by this skill.
 * @property {string} fullKey      Fully written key used as alternate for enrichers.
 * @property {string} [reference]  Reference to a rule page describing this skill.
 */

/**
 * The set of skill which can be trained with their default ability scores.
 * @enum {SkillConfiguration}
 */
DND5E.skills = {
  acr: {
    label: "DND5E.SkillAcr",
    ability: "dex",
    fullKey: "acrobatics",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AvvBLEHNl7kuwPkN",
    icon: "icons/equipment/feet/shoes-simple-leaf-green.webp"
  },
  ani: {
    label: "DND5E.SkillAni",
    ability: "wis",
    fullKey: "animalHandling",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.xb3MCjUvopOU4viE",
    icon: "icons/environment/creatures/horse-brown.webp"
  },
  arc: {
    label: "DND5E.SkillArc",
    ability: "int",
    fullKey: "arcana",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.h3bYSPge8IOqne1N",
    icon: "icons/sundries/books/book-embossed-jewel-silver-green.webp"
  },
  ath: {
    label: "DND5E.SkillAth",
    ability: "str",
    fullKey: "athletics",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.rIR7ttYDUpH3tMzv",
    icon: "icons/magic/control/buff-strength-muscle-damage-orange.webp"
  },
  dec: {
    label: "DND5E.SkillDec",
    ability: "cha",
    fullKey: "deception",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.mqVZ2fz0L7a9VeKJ",
    icon: "icons/magic/control/mouth-smile-deception-purple.webp"
  },
  his: {
    label: "DND5E.SkillHis",
    ability: "int",
    fullKey: "history",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kRBZbdWMGW9K3wdY",
    icon: "icons/sundries/books/book-embossed-bound-brown.webp"
  },
  ins: {
    label: "DND5E.SkillIns",
    ability: "wis",
    fullKey: "insight",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8R5SMbAGbECNgO8z",
    icon: "icons/magic/perception/orb-crystal-ball-scrying-blue.webp"
  },
  itm: {
    label: "DND5E.SkillItm",
    ability: "cha",
    fullKey: "intimidation",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4VHHI2gJ1jEsppfg",
    icon: "icons/skills/social/intimidation-impressing.webp"
  },
  inv: {
    label: "DND5E.SkillInv",
    ability: "int",
    fullKey: "investigation",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Y7nmbQAruWOs7WRM",
    icon: "icons/tools/scribal/magnifying-glass.webp"
  },
  med: {
    label: "DND5E.SkillMed",
    ability: "wis",
    fullKey: "medicine",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.GeYmM7BVfSCAga4o",
    icon: "icons/tools/cooking/mortar-herbs-yellow.webp"
  },
  nat: {
    label: "DND5E.SkillNat",
    ability: "int",
    fullKey: "nature",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ueMx3uF2PQlcye31",
    icon: "icons/magic/nature/plant-sprout-snow-green.webp"
  },
  prc: {
    label: "DND5E.SkillPrc",
    ability: "wis",
    fullKey: "perception",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zjEeHCUqfuprfzhY",
    icon: "icons/magic/perception/eye-ringed-green.webp"
  },
  prf: {
    label: "DND5E.SkillPrf",
    ability: "cha",
    fullKey: "performance",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hYT7Z06yDNBcMtGe",
    icon: "icons/tools/instruments/lute-gold-brown.webp"
  },
  per: {
    label: "DND5E.SkillPer",
    ability: "cha",
    fullKey: "persuasion",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4R5H8iIsdFQTsj3X",
    icon: "icons/skills/social/diplomacy-handshake.webp"
  },
  rel: {
    label: "DND5E.SkillRel",
    ability: "int",
    fullKey: "religion",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.CXVzERHdP4qLhJXM",
    icon: "icons/magic/holy/saint-glass-portrait-halo.webp"
  },
  slt: {
    label: "DND5E.SkillSlt",
    ability: "dex",
    fullKey: "sleightOfHand",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.yg6SRpGNVz9nDW0A",
    icon: "icons/sundries/gaming/playing-cards.webp"
  },
  ste: {
    label: "DND5E.SkillSte",
    ability: "dex",
    fullKey: "stealth",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4MfrpERNiQXmvgCI",
    icon: "icons/magic/perception/shadow-stealth-eyes-purple.webp"
  },
  sur: {
    label: "DND5E.SkillSur",
    ability: "wis",
    fullKey: "survival",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.t3EzDU5b9BVAIEVi",
    icon: "icons/magic/fire/flame-burning-campfire-yellow-blue.webp"
  }
};
preLocalize("skills", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * Character alignment options.
 * @enum {string}
 */
DND5E.alignments = {
  lg: "DND5E.AlignmentLG",
  ng: "DND5E.AlignmentNG",
  cg: "DND5E.AlignmentCG",
  ln: "DND5E.AlignmentLN",
  tn: "DND5E.AlignmentTN",
  cn: "DND5E.AlignmentCN",
  le: "DND5E.AlignmentLE",
  ne: "DND5E.AlignmentNE",
  ce: "DND5E.AlignmentCE"
};
preLocalize("alignments");

/* -------------------------------------------- */

/**
 * An enumeration of item attunement types.
 * @enum {string}
 */
DND5E.attunementTypes = {
  required: "DND5E.AttunementRequired",
  optional: "DND5E.AttunementOptional"
};
preLocalize("attunementTypes");

/**
 * An enumeration of item attunement states.
 * @type {{"0": string, "1": string, "2": string}}
 * @deprecated since 3.2, available until 3.4
 */
DND5E.attunements = {
  0: "DND5E.AttunementNone",
  1: "DND5E.AttunementRequired",
  2: "DND5E.AttunementAttuned"
};
preLocalize("attunements");

/* -------------------------------------------- */
/*  Weapon Details                              */
/* -------------------------------------------- */

/**
 * The set of types which a weapon item can take.
 * @enum {string}
 */
DND5E.weaponTypes = {
  simpleM: "DND5E.WeaponSimpleM",
  simpleR: "DND5E.WeaponSimpleR",
  martialM: "DND5E.WeaponMartialM",
  martialR: "DND5E.WeaponMartialR",
  natural: "DND5E.WeaponNatural",
  improv: "DND5E.WeaponImprov",
  siege: "DND5E.WeaponSiege"
};
preLocalize("weaponTypes");

/* -------------------------------------------- */

/**
 * General weapon categories.
 * @enum {string}
 */
DND5E.weaponProficiencies = {
  sim: "DND5E.WeaponSimpleProficiency",
  mar: "DND5E.WeaponMartialProficiency"
};
preLocalize("weaponProficiencies");

/* -------------------------------------------- */

/**
 * @typedef {object} WeaponMasterConfiguration
 * @property {string} label        Localized label for the mastery
 * @property {string} [reference]  Reference to a rule page describing this mastery.
 */

/**
 * Weapon masteries.
 * @enum {WeaponMasterConfiguration}
 */
DND5E.weaponMasteries = {
  cleave: {
    label: "DND5E.WEAPON.Mastery.Cleave"
  },
  graze: {
    label: "DND5E.WEAPON.Mastery.Graze"
  },
  nick: {
    label: "DND5E.WEAPON.Mastery.Nick"
  },
  push: {
    label: "DND5E.WEAPON.Mastery.Push"
  },
  sap: {
    label: "DND5E.WEAPON.Mastery.Sap"
  },
  slow: {
    label: "DND5E.WEAPON.Mastery.Slow"
  },
  topple: {
    label: "DND5E.WEAPON.Mastery.Topple"
  },
  vex: {
    label: "DND5E.WEAPON.Mastery.Vex"
  }
};
preLocalize("weaponMasteries", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * A mapping between `DND5E.weaponTypes` and `DND5E.weaponProficiencies` that
 * is used to determine if character has proficiency when adding an item.
 * @enum {(boolean|string)}
 */
DND5E.weaponProficienciesMap = {
  simpleM: "sim",
  simpleR: "sim",
  martialM: "mar",
  martialR: "mar"
};

/* -------------------------------------------- */

/**
 * A mapping between `DND5E.weaponTypes` and `DND5E.attackClassifications`. Unlisted types are assumed to be
 * of the "weapon" classification.
 * @enum {string}
 */
DND5E.weaponClassificationMap = {};

/* -------------------------------------------- */

/**
 * A mapping between `DND5E.weaponTypes` and `DND5E.attackTypes`.
 * @enum {string}
 */
DND5E.weaponTypeMap = {
  simpleM: "melee",
  simpleR: "ranged",
  martialM: "melee",
  martialR: "ranged",
  siege: "ranged"
};

/* -------------------------------------------- */

/**
 * The basic weapon types in 5e. This enables specific weapon proficiencies or
 * starting equipment provided by classes and backgrounds.
 * @enum {string}
 */
DND5E.weaponIds = {
  battleaxe: "I0WocDSuNpGJayPb",
  blowgun: "wNWK6yJMHG9ANqQV",
  club: "nfIRTECQIG81CvM4",
  dagger: "0E565kQUBmndJ1a2",
  dart: "3rCO8MTIdPGSW6IJ",
  flail: "UrH3sMdnUDckIHJ6",
  glaive: "rOG1OM2ihgPjOvFW",
  greataxe: "1Lxk6kmoRhG8qQ0u",
  greatclub: "QRCsxkCwWNwswL9o",
  greatsword: "xMkP8BmFzElcsMaR",
  halberd: "DMejWAc8r8YvDPP1",
  handaxe: "eO7Fbv5WBk5zvGOc",
  handcrossbow: "qaSro7kFhxD6INbZ",
  heavycrossbow: "RmP0mYRn2J7K26rX",
  javelin: "DWLMnODrnHn8IbAG",
  lance: "RnuxdHUAIgxccVwj",
  lightcrossbow: "ddWvQRLmnnIS0eLF",
  lighthammer: "XVK6TOL4sGItssAE",
  longbow: "3cymOVja8jXbzrdT",
  longsword: "10ZP2Bu3vnCuYMIB",
  mace: "Ajyq6nGwF7FtLhDQ",
  maul: "DizirD7eqjh8n95A",
  morningstar: "dX8AxCh9o0A9CkT3",
  net: "aEiM49V8vWpWw7rU",
  pike: "tC0kcqZT9HHAO0PD",
  quarterstaff: "g2dWN7PQiMRYWzyk",
  rapier: "Tobce1hexTnDk4sV",
  scimitar: "fbC0Mg1a73wdFbqO",
  shortsword: "osLzOwQdPtrK3rQH",
  sickle: "i4NeNZ30ycwPDHMx",
  spear: "OG4nBBydvmfWYXIk",
  shortbow: "GJv6WkD7D2J6rP6M",
  sling: "3gynWO9sN4OLGMWD",
  trident: "F65ANO66ckP8FDMa",
  warpick: "2YdfjN1PIIrSHZii",
  warhammer: "F0Df164Xv1gWcYt0",
  whip: "QKTyxoO0YDnAsbYe"
};

/* -------------------------------------------- */

/**
 * The basic ammunition types.
 * @enum {string}
 */
DND5E.ammoIds = {
  arrow: "3c7JXOzsv55gqJS5",
  blowgunNeedle: "gBQ8xqTA5f8wP5iu",
  crossbowBolt: "SItCnYBqhzqBoaWG",
  slingBullet: "z9SbsMIBZzuhZOqT"
};

/* -------------------------------------------- */
/*  Bastion Facilities                          */
/* -------------------------------------------- */

/**
 * @typedef FacilityConfiguration
 * @property {Record<string, Record<number, number>>} advancement  The number of free facilities of a given type awarded
 *                                                                 at certain character levels.
 * @property {Record<string, FacilityOrder>} orders                Orders that can be issued to a facility.
 * @property {Record<string, FacilitySize>} sizes                  Facility size categories.
 * @property {Record<string, SubtypeTypeConfiguration>} types      Facility types and subtypes.
 */

/**
 * @typedef FacilityOrder
 * @property {string} label       The human-readable name of the order.
 * @property {string} icon        The SVG icon for this order.
 * @property {boolean} [basic]    Whether this order can be issued to basic facilities.
 * @property {number} [duration]  The amount of time taken to complete the order if different to a normal bastion turn.
 * @property {boolean} [hidden]   This order is not normally available for execution.
 */

/**
 * @typedef FacilitySize
 * @property {string} label    The human-readable name of the size category.
 * @property {number} days     The number of days to build the facility.
 * @property {number} squares  The maximum area the facility may occupy in the bastion plan.
 * @property {number} value    The cost in gold pieces to build the facility.
 */

/**
 * Configuration data for bastion facilities.
 * @type {FacilityConfiguration}
 */
DND5E.facilities = {
  advancement: {
    basic: { 5: 2 },
    special: { 5: 2, 9: 4, 13: 5, 17: 6 }
  },
  orders: {
    build: {
      label: "DND5E.FACILITY.Orders.build.inf",
      icon: "systems/dnd5e/icons/svg/facilities/build.svg"
    },
    change: {
      label: "DND5E.FACILITY.Orders.change.inf",
      icon: "systems/dnd5e/icons/svg/facilities/change.svg",
      duration: 21
    },
    craft: {
      label: "DND5E.FACILITY.Orders.craft.inf",
      icon: "systems/dnd5e/icons/svg/facilities/craft.svg"
    },
    empower: {
      label: "DND5E.FACILITY.Orders.empower.inf",
      icon: "systems/dnd5e/icons/svg/facilities/empower.svg"
    },
    enlarge: {
      label: "DND5E.FACILITY.Orders.enlarge.inf",
      icon: "systems/dnd5e/icons/svg/facilities/enlarge.svg",
      basic: true
    },
    harvest: {
      label: "DND5E.FACILITY.Orders.harvest.inf",
      icon: "systems/dnd5e/icons/svg/facilities/harvest.svg"
    },
    maintain: {
      label: "DND5E.FACILITY.Orders.maintain.inf",
      icon: "systems/dnd5e/icons/svg/facilities/maintain.svg"
    },
    recruit: {
      label: "DND5E.FACILITY.Orders.recruit.inf",
      icon: "systems/dnd5e/icons/svg/facilities/recruit.svg"
    },
    repair: {
      label: "DND5E.FACILITY.Orders.repair.inf",
      icon: "systems/dnd5e/icons/svg/facilities/repair.svg",
      hidden: true
    },
    research: {
      label: "DND5E.FACILITY.Orders.research.inf",
      icon: "systems/dnd5e/icons/svg/facilities/research.svg"
    },
    trade: {
      label: "DND5E.FACILITY.Orders.trade.inf",
      icon: "systems/dnd5e/icons/svg/facilities/trade.svg"
    }
  },
  sizes: {
    cramped: {
      label: "DND5E.FACILITY.Sizes.cramped",
      days: 20,
      squares: 4,
      value: 500
    },
    roomy: {
      label: "DND5E.FACILITY.Sizes.roomy",
      days: 45,
      squares: 16,
      value: 1_000
    },
    vast: {
      label: "DND5E.FACILITY.Sizes.vast",
      days: 125,
      squares: 36,
      value: 3_000
    }
  },
  types: {
    basic: {
      label: "DND5E.FACILITY.Types.Basic.Label.one",
      subtypes: {
        bedroom: "DND5E.FACILITY.Types.Basic.Bedroom",
        diningRoom: "DND5E.FACILITY.Types.Basic.DiningRoom",
        parlor: "DND5E.FACILITY.Types.Basic.Parlor",
        courtyard: "DND5E.FACILITY.Types.Basic.Courtyard",
        kitchen: "DND5E.FACILITY.Types.Basic.Kitchen",
        storage: "DND5E.FACILITY.Types.Basic.Storage"
      }
    },
    special: {
      label: "DND5E.FACILITY.Types.Special.Label.one",
      subtypes: {
        arcaneStudy: "DND5E.FACILITY.Types.Special.ArcaneStudy",
        armory: "DND5E.FACILITY.Types.Special.Armory",
        barrack: "DND5E.FACILITY.Types.Special.Barrack",
        garden: "DND5E.FACILITY.Types.Special.Garden",
        library: "DND5E.FACILITY.Types.Special.Library",
        sanctuary: "DND5E.FACILITY.Types.Special.Sanctuary",
        smithy: "DND5E.FACILITY.Types.Special.Smithy",
        storehouse: "DND5E.FACILITY.Types.Special.Storehouse",
        workshop: "DND5E.FACILITY.Types.Special.Workshop",
        gamingHall: "DND5E.FACILITY.Types.Special.GamingHall",
        greenhouse: "DND5E.FACILITY.Types.Special.Greenhouse",
        laboratory: "DND5E.FACILITY.Types.Special.Laboratory",
        sacristy: "DND5E.FACILITY.Types.Special.Sacristy",
        scriptorium: "DND5E.FACILITY.Types.Special.Scriptorium",
        stable: "DND5E.FACILITY.Types.Special.Stable",
        teleportationCircle: "DND5E.FACILITY.Types.Special.TeleportationCircle",
        theater: "DND5E.FACILITY.Types.Special.Theater",
        trainingArea: "DND5E.FACILITY.Types.Special.TrainingArea",
        trophyRoom: "DND5E.FACILITY.Types.Special.TrophyRoom",
        archive: "DND5E.FACILITY.Types.Special.Archive",
        meditationChamber: "DND5E.FACILITY.Types.Special.MeditationChamber",
        menagerie: "DND5E.FACILITY.Types.Special.Menagerie",
        observatory: "DND5E.FACILITY.Types.Special.Observatory",
        pub: "DND5E.FACILITY.Types.Special.Pub",
        reliquary: "DND5E.FACILITY.Types.Special.Reliquary",
        demiplane: "DND5E.FACILITY.Types.Special.Demiplane",
        guildhall: "DND5E.FACILITY.Types.Special.Guildhall",
        sanctum: "DND5E.FACILITY.Types.Special.Sanctum",
        warRoom: "DND5E.FACILITY.Types.Special.WarRoom"
      }
    }
  }
};
preLocalize("facilities.orders", { key: "label", sort: true });
preLocalize("facilities.sizes", { key: "label", sort: true });
preLocalize("facilities.types", { key: "label", sort: true });
preLocalize("facilities.types.basic.subtypes", { sort: true });
preLocalize("facilities.types.special.subtypes", { sort: true });

/* -------------------------------------------- */
/*  Tool Details                                */
/* -------------------------------------------- */

/**
 * The categories into which Tool items can be grouped.
 *
 * @enum {string}
 */
DND5E.toolTypes = {
  art: "DND5E.ToolArtisans",
  game: "DND5E.ToolGamingSet",
  music: "DND5E.ToolMusicalInstrument"
};
preLocalize("toolTypes", { sort: true });

/**
 * The categories of tool proficiencies that a character can gain.
 *
 * @enum {string}
 */
DND5E.toolProficiencies = {
  ...DND5E.toolTypes,
  vehicle: "DND5E.ToolVehicle"
};
preLocalize("toolProficiencies", { sort: true });

/**
 * @typedef ToolConfiguration
 * @property {string} ability  Default ability used for the tool.
 * @property {string} id       UUID of reference tool or ID within pack defined by `DND5E.sourcePacks.ITEMS`.
 */

/**
 * Configuration data for tools.
 * @enum {ToolConfiguration}
 */
DND5E.tools = {
  alchemist: {
    ability: "int",
    id: "SztwZhbhZeCqyAes"
  },
  bagpipes: {
    ability: "cha",
    id: "yxHi57T5mmVt0oDr"
  },
  brewer: {
    ability: "int",
    id: "Y9S75go1hLMXUD48"
  },
  calligrapher: {
    ability: "dex",
    id: "jhjo20QoiD5exf09"
  },
  card: {
    ability: "wis",
    id: "YwlHI3BVJapz4a3E"
  },
  carpenter: {
    ability: "str",
    id: "8NS6MSOdXtUqD7Ib"
  },
  cartographer: {
    ability: "wis",
    id: "fC0lFK8P4RuhpfaU"
  },
  chess: {
    ability: "wis",
    id: "23y8FvWKf9YLcnBL"
  },
  cobbler: {
    ability: "dex",
    id: "hM84pZnpCqKfi8XH"
  },
  cook: {
    ability: "wis",
    id: "Gflnp29aEv5Lc1ZM"
  },
  dice: {
    ability: "wis",
    id: "iBuTM09KD9IoM5L8"
  },
  disg: {
    ability: "cha",
    id: "IBhDAr7WkhWPYLVn"
  },
  drum: {
    ability: "cha",
    id: "69Dpr25pf4BjkHKb"
  },
  dulcimer: {
    ability: "cha",
    id: "NtdDkjmpdIMiX7I2"
  },
  flute: {
    ability: "cha",
    id: "eJOrPcAz9EcquyRQ"
  },
  forg: {
    ability: "dex",
    id: "cG3m4YlHfbQlLEOx"
  },
  glassblower: {
    ability: "int",
    id: "rTbVrNcwApnuTz5E"
  },
  herb: {
    ability: "int",
    id: "i89okN7GFTWHsvPy"
  },
  horn: {
    ability: "cha",
    id: "aa9KuBy4dst7WIW9"
  },
  jeweler: {
    ability: "int",
    id: "YfBwELTgPFHmQdHh"
  },
  leatherworker: {
    ability: "dex",
    id: "PUMfwyVUbtyxgYbD"
  },
  lute: {
    ability: "cha",
    id: "qBydtUUIkv520DT7"
  },
  lyre: {
    ability: "cha",
    id: "EwG1EtmbgR3bM68U"
  },
  mason: {
    ability: "str",
    id: "skUih6tBvcBbORzA"
  },
  navg: {
    ability: "wis",
    id: "YHCmjsiXxZ9UdUhU"
  },
  painter: {
    ability: "wis",
    id: "ccm5xlWhx74d6lsK"
  },
  panflute: {
    ability: "cha",
    id: "G5m5gYIx9VAUWC3J"
  },
  pois: {
    ability: "int",
    id: "il2GNi8C0DvGLL9P"
  },
  potter: {
    ability: "int",
    id: "hJS8yEVkqgJjwfWa"
  },
  shawm: {
    ability: "cha",
    id: "G3cqbejJpfB91VhP"
  },
  smith: {
    ability: "str",
    id: "KndVe2insuctjIaj"
  },
  thief: {
    ability: "dex",
    id: "woWZ1sO5IUVGzo58"
  },
  tinker: {
    ability: "dex",
    id: "0d08g1i5WXnNrCNA"
  },
  viol: {
    ability: "cha",
    id: "baoe3U5BfMMMxhCU"
  },
  weaver: {
    ability: "dex",
    id: "ap9prThUB2y9lDyj"
  },
  woodcarver: {
    ability: "dex",
    id: "xKErqkLo4ASYr5EP"
  }
};

/**
 * The basic tool types in 5e. This enables specific tool proficiencies or
 * starting equipment provided by classes and backgrounds.
 * @enum {string}
 */
DND5E.toolIds = new Proxy(DND5E.tools, {
  get(target, prop) {
    return target[prop]?.id ?? target[prop];
  },
  set(target, prop, value) {
    foundry.utils.logCompatibilityWarning(
      "Appending to CONFIG.DND5E.toolIds is deprecated, use CONFIG.DND5E.tools instead.",
      { since: "DnD5e 4.0", until: "DnD5e 4.2", once: true }
    );
    target[prop] ??= { ability: "int" };
    target[prop].id = value;
    return true;
  }
});

/* -------------------------------------------- */
/*  Time                                        */
/* -------------------------------------------- */

/**
 * @typedef {object} TimeUnitConfiguration
 * @property {string} label            Localized label for this unit.
 * @property {string} [counted]        Localization path for counted plural forms. Only necessary if non-supported unit
 *                                     or using non-standard name for a supported unit. List of supported units can be
 *                                     found here: https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers
 * @property {number} conversion       Conversion multiplier used to converting between units.
 * @property {boolean} [combat=false]  Is this a combat-specific time unit?
 * @property {boolean} [option=true]   Should this be available when users can select from a list of units?
 */

/**
 * Configuration for time units available to the system.
 * @enum {TimeUnitConfiguration}
 */
DND5E.timeUnits = {
  turn: {
    label: "DND5E.UNITS.TIME.Turn.Label",
    counted: "DND5E.UNITS.TIME.Turn.Counted",
    conversion: .1,
    combat: true
  },
  round: {
    label: "DND5E.UNITS.TIME.Round.Label",
    counted: "DND5E.UNITS.TIME.Round.Counted",
    conversion: .1,
    combat: true
  },
  second: {
    label: "DND5E.UNITS.TIME.Second.Label",
    conversion: 1 / 60,
    option: false
  },
  minute: {
    label: "DND5E.UNITS.TIME.Minute.Label",
    conversion: 1
  },
  hour: {
    label: "DND5E.UNITS.TIME.Hour.Label",
    conversion: 60
  },
  day: {
    label: "DND5E.UNITS.TIME.Day.Label",
    conversion: 1_440
  },
  week: {
    label: "DND5E.UNITS.TIME.Week.Label",
    conversion: 10_080,
    option: false
  },
  month: {
    label: "DND5E.UNITS.TIME.Month.Label",
    conversion: 43_200
  },
  year: {
    label: "DND5E.UNITS.TIME.Year.Label",
    conversion: 525_600
  }
};
preLocalize("timeUnits", { key: "label" });

/* -------------------------------------------- */

/**
 * Time periods that accept a numeric value.
 * @enum {string}
 */
DND5E.scalarTimePeriods = new Proxy(DND5E.timeUnits, {
  get(target, prop) {
    return target[prop]?.label;
  },
  has(target, key) {
    return target[key] && target[key].option !== false;
  },
  set(target, prop, value) {
    foundry.utils.logCompatibilityWarning(
      "Appending to CONFIG.DND5E.scalarTimePeriods is deprecated, use CONFIG.DND5E.timeUnits instead.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4", once: true }
    );
    target[prop] ??= {};
    target[prop].label = value;
  },
  ownKeys(target) {
    return Object.keys(target).filter(k => target[k]?.option !== false);
  }
});

/* -------------------------------------------- */

/**
 * Time periods for spells that don't have a defined ending.
 * @enum {string}
 */
DND5E.permanentTimePeriods = {
  disp: "DND5E.TimeDisp",
  dstr: "DND5E.TimeDispTrig",
  perm: "DND5E.TimePerm"
};
preLocalize("permanentTimePeriods");

/* -------------------------------------------- */

/**
 * Time periods that don't accept a numeric value.
 * @enum {string}
 */
DND5E.specialTimePeriods = {
  inst: "DND5E.TimeInst",
  spec: "DND5E.Special"
};
preLocalize("specialTimePeriods");

/* -------------------------------------------- */

/**
 * The various lengths of time over which effects can occur.
 * @enum {string}
 */
DND5E.timePeriods = {
  ...DND5E.specialTimePeriods,
  ...DND5E.permanentTimePeriods,
  ...DND5E.scalarTimePeriods
};
preLocalize("timePeriods");

/* -------------------------------------------- */

/**
 * Ways in which to activate an item that cannot be labeled with a cost.
 * @enum {string}
 */
DND5E.staticAbilityActivationTypes = {
  none: "DND5E.NoneActionLabel",
  special: DND5E.timePeriods.spec
};

/**
 * Various ways in which an item or ability can be activated.
 * @enum {string}
 */
DND5E.abilityActivationTypes = {
  ...DND5E.staticAbilityActivationTypes,
  action: "DND5E.Action",
  bonus: "DND5E.BonusAction",
  reaction: "DND5E.Reaction",
  minute: DND5E.timePeriods.minute,
  hour: DND5E.timePeriods.hour,
  day: DND5E.timePeriods.day,
  legendary: "DND5E.LegendaryAction.Label",
  mythic: "DND5E.MythicActionLabel",
  lair: "DND5E.LAIR.Action.Label",
  crew: "DND5E.VehicleCrewAction"
};
preLocalize("abilityActivationTypes");

/* -------------------------------------------- */

/**
 * @typedef {ActivityActivationTypeConfig}
 * @property {string} label             Localized label for the activation type.
 * @property {string} [group]           Localized label for the presentational group.
 * @property {boolean} [passive=false]  Classify this item as a passive feature on NPC sheets.
 * @property {boolean} [scalar=false]   Does this activation type have a numeric value attached?
 */

/**
 * Configuration data for activation types on activities.
 * @enum {ActivityActivationTypeConfig}
 */
DND5E.activityActivationTypes = {
  action: {
    label: "DND5E.Action",
    group: "DND5E.ACTIVATION.Category.Standard"
  },
  bonus: {
    label: "DND5E.BonusAction",
    group: "DND5E.ACTIVATION.Category.Standard"
  },
  reaction: {
    label: "DND5E.Reaction",
    group: "DND5E.ACTIVATION.Category.Standard"
  },
  minute: {
    label: "DND5E.TimeMinute",
    group: "DND5E.ACTIVATION.Category.Time",
    scalar: true
  },
  hour: {
    label: "DND5E.TimeHour",
    group: "DND5E.ACTIVATION.Category.Time",
    scalar: true
  },
  day: {
    label: "DND5E.TimeDay",
    group: "DND5E.ACTIVATION.Category.Time",
    scalar: true
  },
  longRest: {
    label: "DND5E.ACTIVATION.Type.LongRest.Label",
    group: "DND5E.ACTIVATION.Category.Rest",
    passive: true
  },
  shortRest: {
    label: "DND5E.ACTIVATION.Type.ShortRest.Label",
    group: "DND5E.ACTIVATION.Category.Rest",
    passive: true
  },
  encounter: {
    label: "DND5E.ACTIVATION.Type.Encounter.Label",
    group: "DND5E.ACTIVATION.Category.Combat",
    passive: true
  },
  turnStart: {
    label: "DND5E.ACTIVATION.Type.TurnStart.Label",
    group: "DND5E.ACTIVATION.Category.Combat",
    passive: true
  },
  turnEnd: {
    label: "DND5E.ACTIVATION.Type.TurnEnd.Label",
    group: "DND5E.ACTIVATION.Category.Combat",
    passive: true
  },
  legendary: {
    label: "DND5E.LegendaryAction.Label",
    group: "DND5E.ACTIVATION.Category.Monster",
    scalar: true
  },
  mythic: {
    label: "DND5E.MythicActionLabel",
    group: "DND5E.ACTIVATION.Category.Monster",
    scalar: true
  },
  lair: {
    label: "DND5E.LAIR.Action.Label",
    group: "DND5E.ACTIVATION.Category.Monster"
  },
  crew: {
    label: "DND5E.VehicleCrewAction",
    group: "DND5E.ACTIVATION.Category.Vehicle",
    scalar: true
  },
  special: {
    label: "DND5E.Special"
  }
};
preLocalize("activityActivationTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Different things that an ability can consume upon use.
 * @enum {string}
 */
DND5E.abilityConsumptionTypes = {
  ammo: "DND5E.ConsumeAmmunition",
  attribute: "DND5E.ConsumeAttribute",
  hitDice: "DND5E.ConsumeHitDice",
  material: "DND5E.ConsumeMaterial",
  charges: "DND5E.ConsumeCharges"
};
preLocalize("abilityConsumptionTypes", { sort: true });

/* -------------------------------------------- */

/**
 * @typedef {object} ActivityConsumptionTargetConfig
 * @property {string} label                                     Localized label for the target type.
 * @property {ConsumptionConsumeFunction} consume               Function used to consume according to this type.
 * @property {ConsumptionLabelsFunction} consumptionLabels      Function used to generate a hint of consumption amount.
 * @property {{value: string, label: string}[]} [scalingModes]  Additional scaling modes for this consumption type in
 *                                                              addition to the default "amount" scaling.
 * @property {boolean} [targetRequiresEmbedded]                 Use text input rather than select when not embedded.
 * @property {ConsumptionValidTargetsFunction} [validTargets]   Function for creating an array of consumption targets.
 */

/**
 * @callback ConsumptionConsumeFunction
 * @this {ConsumptionTargetData}
 * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
 * @param {ActivityUsageUpdates} updates     Updates to be performed.
 * @throws ConsumptionError
 */

/**
 * @callback ConsumptionLabelsFunction
 * @this {ConsumptionTargetData}
 * @param {ActivityUseConfiguration} config  Configuration data for the activity usage.
 * @param {object} [options={}]
 * @param {boolean} [options.consumed]       Is this consumption currently set to be consumed?
 * @returns {ConsumptionLabels}
 */

/**
 * @typedef ConsumptionLabels
 * @property {string} label      Label displayed for the consumption checkbox.
 * @property {string} hint       Hint text describing what should be consumed.
 * @property {{ type: string, message: string }} [notes]  Additional notes relating to the consumption to be performed.
 * @property {boolean} [warn]    Display a warning icon indicating consumption will fail.
 */

/**
 * @callback ConsumptionValidTargetsFunction
 * @this {ConsumptionTargetData}
 * @returns {FormSelectOption[]}
 */

/**
 * Configuration information for different consumption targets.
 * @enum {ActivityConsumptionTargetConfig}
 */
DND5E.activityConsumptionTypes = {
  activityUses: {
    label: "DND5E.CONSUMPTION.Type.ActivityUses.Label",
    consume: ConsumptionTargetData.consumeActivityUses,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsActivityUses
  },
  itemUses: {
    label: "DND5E.CONSUMPTION.Type.ItemUses.Label",
    consume: ConsumptionTargetData.consumeItemUses,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsItemUses,
    targetRequiresEmbedded: true,
    validTargets: ConsumptionTargetData.validItemUsesTargets
  },
  material: {
    label: "DND5E.CONSUMPTION.Type.Material.Label",
    consume: ConsumptionTargetData.consumeMaterial,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsMaterial,
    targetRequiresEmbedded: true,
    validTargets: ConsumptionTargetData.validMaterialTargets
  },
  hitDice: {
    label: "DND5E.CONSUMPTION.Type.HitDice.Label",
    consume: ConsumptionTargetData.consumeHitDice,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsHitDice,
    validTargets: ConsumptionTargetData.validHitDiceTargets
  },
  spellSlots: {
    label: "DND5E.CONSUMPTION.Type.SpellSlots.Label",
    consume: ConsumptionTargetData.consumeSpellSlots,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsSpellSlots,
    scalingModes: [{ value: "level", label: "DND5E.CONSUMPTION.Scaling.SlotLevel" }],
    validTargets: ConsumptionTargetData.validSpellSlotsTargets
  },
  attribute: {
    label: "DND5E.CONSUMPTION.Type.Attribute.Label",
    consume: ConsumptionTargetData.consumeAttribute,
    consumptionLabels: ConsumptionTargetData.consumptionLabelsAttribute,
    targetRequiresEmbedded: true,
    validTargets: ConsumptionTargetData.validAttributeTargets
  }
};
preLocalize("activityConsumptionTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Configuration data for actor sizes.
 *
 * @typedef {object} ActorSizeConfiguration
 * @property {string} label                   Localized label.
 * @property {string} abbreviation            Localized abbreviation.
 * @property {number} hitDie                  Default hit die denomination for NPCs of this size.
 * @property {number} [token=1]               Default token size.
 * @property {number} [capacityMultiplier=1]  Multiplier used to calculate carrying capacities.
 */

/**
 * Creature sizes ordered from smallest to largest.
 * @enum {ActorSizeConfiguration}
 */
DND5E.actorSizes = {
  tiny: {
    label: "DND5E.SizeTiny",
    abbreviation: "DND5E.SizeTinyAbbr",
    hitDie: 4,
    token: 0.5,
    capacityMultiplier: 0.5
  },
  sm: {
    label: "DND5E.SizeSmall",
    abbreviation: "DND5E.SizeSmallAbbr",
    hitDie: 6,
    dynamicTokenScale: 0.8
  },
  med: {
    label: "DND5E.SizeMedium",
    abbreviation: "DND5E.SizeMediumAbbr",
    hitDie: 8
  },
  lg: {
    label: "DND5E.SizeLarge",
    abbreviation: "DND5E.SizeLargeAbbr",
    hitDie: 10,
    token: 2,
    capacityMultiplier: 2
  },
  huge: {
    label: "DND5E.SizeHuge",
    abbreviation: "DND5E.SizeHugeAbbr",
    hitDie: 12,
    token: 3,
    capacityMultiplier: 4
  },
  grg: {
    label: "DND5E.SizeGargantuan",
    abbreviation: "DND5E.SizeGargantuanAbbr",
    hitDie: 20,
    token: 4,
    capacityMultiplier: 8
  }
};
preLocalize("actorSizes", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */
/*  Canvas                                      */
/* -------------------------------------------- */

/**
 * Colors used to visualize temporary and temporary maximum HP in token health bars.
 * @enum {number}
 */
DND5E.tokenHPColors = {
  damage: 0xFF0000,
  healing: 0x00FF00,
  temp: 0x66CCFF,
  tempmax: 0x440066,
  negmax: 0x550000
};

/* -------------------------------------------- */

/**
 * Colors used when a dynamic token ring effects.
 * @enum {number}
 */
DND5E.tokenRingColors = {
  damage: 0xFF0000,
  defeated: 0x000000,
  healing: 0x00FF00,
  temp: 0x33AAFF
};

/* -------------------------------------------- */

/**
 * Configuration data for a map marker style. Options not included will fall back to the value set in `default` style.
 * Any additional styling options added will be passed into the custom marker class and be available for rendering.
 *
 * @typedef {object} MapLocationMarkerStyle
 * @property {typeof PIXI.Container} [icon]  Map marker class used to render the icon.
 * @property {number} [backgroundColor]      Color of the background inside the circle.
 * @property {number} [borderColor]          Color of the border in normal state.
 * @property {number} [borderHoverColor]     Color of the border when hovering over the marker.
 * @property {string} [fontFamily]           Font used for rendering the code on the marker.
 * @property {number} [shadowColor]          Color of the shadow under the marker.
 * @property {number} [textColor]            Color of the text on the marker.
 */

/**
 * Settings used to render map location markers on the canvas.
 * @enum {MapLocationMarkerStyle}
 */
DND5E.mapLocationMarker = {
  default: {
    icon: MapLocationControlIcon,
    backgroundColor: 0xFBF8F5,
    borderColor: 0x000000,
    borderHoverColor: 0xFF5500,
    fontFamily: "Roboto Slab",
    shadowColor: 0x000000,
    textColor: 0x000000
  }
};

/* -------------------------------------------- */

/**
 * Configuration data for creature types.
 *
 * @typedef {object} CreatureTypeConfiguration
 * @property {string} label               Localized label.
 * @property {string} plural              Localized plural form used in swarm name.
 * @property {string} [reference]         Reference to a rule page describing this type.
 * @property {boolean} [detectAlignment]  Is this type detectable by spells such as "Detect Evil and Good"?
 */

/**
 * Default types of creatures.
 * @enum {CreatureTypeConfiguration}
 */
DND5E.creatureTypes = {
  aberration: {
    label: "DND5E.CreatureAberration",
    plural: "DND5E.CreatureAberrationPl",
    icon: "icons/creatures/tentacles/tentacle-eyes-yellow-pink.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.yy50qVC1JhPHt4LC",
    detectAlignment: true
  },
  beast: {
    label: "DND5E.CreatureBeast",
    plural: "DND5E.CreatureBeastPl",
    icon: "icons/creatures/claws/claw-bear-paw-swipe-red.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6bTHn7pZek9YX2tv"
  },
  celestial: {
    label: "DND5E.CreatureCelestial",
    plural: "DND5E.CreatureCelestialPl",
    icon: "icons/creatures/abilities/wings-birdlike-blue.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.T5CJwxjhBbi6oqaM",
    detectAlignment: true
  },
  construct: {
    label: "DND5E.CreatureConstruct",
    plural: "DND5E.CreatureConstructPl",
    icon: "icons/creatures/magical/construct-stone-earth-gray.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jQGAJZBZTqDFod8d"
  },
  dragon: {
    label: "DND5E.CreatureDragon",
    plural: "DND5E.CreatureDragonPl",
    icon: "icons/creatures/abilities/dragon-fire-breath-orange.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.k2IRXZwGk9W0PM2S"
  },
  elemental: {
    label: "DND5E.CreatureElemental",
    plural: "DND5E.CreatureElementalPl",
    icon: "icons/creatures/magical/spirit-fire-orange.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.7z1LXGGkXpHuzkFh",
    detectAlignment: true
  },
  fey: {
    label: "DND5E.CreatureFey",
    plural: "DND5E.CreatureFeyPl",
    icon: "icons/creatures/magical/fae-fairy-winged-glowing-green.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.OFsRUt3pWljgm8VC",
    detectAlignment: true
  },
  fiend: {
    label: "DND5E.CreatureFiend",
    plural: "DND5E.CreatureFiendPl",
    icon: "icons/magic/death/skull-horned-goat-pentagram-red.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ElHKBJeiJPC7gj6k",
    detectAlignment: true
  },
  giant: {
    label: "DND5E.CreatureGiant",
    plural: "DND5E.CreatureGiantPl",
    icon: "icons/creatures/magical/humanoid-giant-forest-blue.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AOXn3Mv5vPZwo0Uf"
  },
  humanoid: {
    label: "DND5E.CreatureHumanoid",
    plural: "DND5E.CreatureHumanoidPl",
    icon: "icons/environment/people/group.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iFzQs4AenN8ALRvw"
  },
  monstrosity: {
    label: "DND5E.CreatureMonstrosity",
    plural: "DND5E.CreatureMonstrosityPl",
    icon: "icons/creatures/abilities/mouth-teeth-rows-red.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TX0yPEFTn79AMZ8P"
  },
  ooze: {
    label: "DND5E.CreatureOoze",
    plural: "DND5E.CreatureOozePl",
    icon: "icons/creatures/slimes/slime-movement-pseudopods-green.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.cgzIC1ecG03D97Fg"
  },
  plant: {
    label: "DND5E.CreaturePlant",
    plural: "DND5E.CreaturePlantPl",
    icon: "icons/magic/nature/tree-animated-strike.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1oT7t6tHE4kZuSN1"
  },
  undead: {
    label: "DND5E.CreatureUndead",
    plural: "DND5E.CreatureUndeadPl",
    icon: "icons/magic/death/skull-horned-worn-fire-blue.webp",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.D2BdqS1GeD5rcZ6q",
    detectAlignment: true
  }
};
preLocalize("creatureTypes", { keys: ["label", "plural"], sort: true });

/* -------------------------------------------- */

/**
 * Classification types for item action types.
 * @enum {string}
 */
DND5E.itemActionTypes = {
  mwak: "DND5E.ActionMWAK",
  rwak: "DND5E.ActionRWAK",
  msak: "DND5E.ActionMSAK",
  rsak: "DND5E.ActionRSAK",
  abil: "DND5E.ActionAbil",
  save: "DND5E.ActionSave",
  ench: "DND5E.ActionEnch",
  summ: "DND5E.ActionSumm",
  heal: "DND5E.ActionHeal",
  util: "DND5E.ActionUtil",
  other: "DND5E.ActionOther"
};
preLocalize("itemActionTypes");

/* -------------------------------------------- */

/**
 * Different ways in which item capacity can be limited.
 * @enum {string}
 */
DND5E.itemCapacityTypes = {
  items: "DND5E.ItemContainerCapacityItems",
  weight: "DND5E.ItemContainerCapacityWeight"
};
preLocalize("itemCapacityTypes", { sort: true });

/* -------------------------------------------- */

/**
 * List of various item rarities.
 * @enum {string}
 */
DND5E.itemRarity = {
  common: "DND5E.ItemRarityCommon",
  uncommon: "DND5E.ItemRarityUncommon",
  rare: "DND5E.ItemRarityRare",
  veryRare: "DND5E.ItemRarityVeryRare",
  legendary: "DND5E.ItemRarityLegendary",
  artifact: "DND5E.ItemRarityArtifact"
};
preLocalize("itemRarity");

/* -------------------------------------------- */

/**
 * Configuration data for limited use periods.
 *
 * @typedef {object} LimitedUsePeriodConfiguration
 * @property {string} label                Localized label.
 * @property {string}  abbreviation        Shorthand form of the label.
 * @property {"combat"|"special"} [group]  Grouping if outside the normal "time" group.
 * @property {boolean} [formula]           Whether this limited use period restores charges via formula.
 */

/**
 * Enumerate the lengths of time over which an item can have limited use ability.
 * @enum {LimitedUsePeriodConfiguration}
 */
DND5E.limitedUsePeriods = {
  lr: {
    label: "DND5E.USES.Recovery.Period.LongRest.Label",
    abbreviation: "DND5E.USES.Recovery.Period.LongRest.Abbreviation"
  },
  sr: {
    label: "DND5E.USES.Recovery.Period.ShortRest.Label",
    abbreviation: "DND5E.USES.Recovery.Period.ShortRest.Abbreviation"
  },
  day: {
    label: "DND5E.USES.Recovery.Period.Day.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Day.Label"
  },
  // TODO: Remove with DnD5e 4.4
  charges: {
    label: "DND5E.UsesPeriods.Charges",
    abbreviation: "DND5E.UsesPeriods.ChargesAbbreviation",
    formula: true,
    deprecated: true
  },
  dawn: {
    label: "DND5E.USES.Recovery.Period.Dawn.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Dawn.Label",
    formula: true
  },
  dusk: {
    label: "DND5E.USES.Recovery.Period.Dusk.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Dusk.Label",
    formula: true
  },
  initiative: {
    label: "DND5E.USES.Recovery.Period.Initiative.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Initiative.Label",
    type: "special"
  },
  turnStart: {
    label: "DND5E.USES.Recovery.Period.TurnStart.Label",
    abbreviation: "DND5E.USES.Recovery.Period.TurnStart.Abbreviation",
    type: "combat"
  },
  turnEnd: {
    label: "DND5E.USES.Recovery.Period.TurnEnd.Label",
    abbreviation: "DND5E.USES.Recovery.Period.TurnEnd.Abbreviation",
    type: "combat"
  },
  turn: {
    label: "DND5E.USES.Recovery.Period.Turn.Label",
    abbreviation: "DND5E.USES.Recovery.Period.Turn.Label",
    type: "combat"
  }
};
preLocalize("limitedUsePeriods", { keys: ["label", "abbreviation"] });

Object.defineProperty(DND5E.limitedUsePeriods, "recoveryOptions", {
  get() {
    return [
      ...Object.entries(CONFIG.DND5E.limitedUsePeriods)
        .filter(([, config]) => !config.deprecated)
        .map(([value, { label, type }]) => ({
          value, label, group: game.i18n.localize(`DND5E.USES.Recovery.${type?.capitalize() ?? "Time"}`)
        })),
      { value: "recharge", label: game.i18n.localize("DND5E.USES.Recovery.Recharge.Label") }
    ];
  }
});

/* -------------------------------------------- */

/**
 * Periods at which enchantments can be re-bound to new items.
 * @enum {{ label: string }}
 */
DND5E.enchantmentPeriods = {
  sr: {
    label: "DND5E.ENCHANTMENT.Period.ShortRest"
  },
  lr: {
    label: "DND5E.ENCHANTMENT.Period.LongRest"
  },
  atwill: {
    label: "DND5E.ENCHANTMENT.Period.AtWill"
  }
};
preLocalize("enchantmentPeriods", { key: "label" });

/* -------------------------------------------- */

/**
 * Specific equipment types that modify base AC.
 * @enum {string}
 */
DND5E.armorTypes = {
  light: "DND5E.EquipmentLight",
  medium: "DND5E.EquipmentMedium",
  heavy: "DND5E.EquipmentHeavy",
  natural: "DND5E.EquipmentNatural",
  shield: "DND5E.EquipmentShield"
};
preLocalize("armorTypes");

/* -------------------------------------------- */

/**
 * Equipment types that aren't armor.
 * @enum {string}
 */
DND5E.miscEquipmentTypes = {
  clothing: "DND5E.EQUIPMENT.Type.Clothing.Label",
  ring: "DND5E.EQUIPMENT.Type.Ring.Label",
  rod: "DND5E.EQUIPMENT.Type.Rod.Label",
  trinket: "DND5E.EQUIPMENT.Type.Trinket.Label",
  vehicle: "DND5E.EQUIPMENT.Type.Vehicle.Label",
  wand: "DND5E.EQUIPMENT.Type.Wand.Label",
  wondrous: "DND5E.EQUIPMENT.Type.Wondrous.Label"
};
preLocalize("miscEquipmentTypes", { sort: true });

/* -------------------------------------------- */

/**
 * The set of equipment types for armor, clothing, and other objects which can be worn by the character.
 * @enum {string}
 */
DND5E.equipmentTypes = {
  ...DND5E.miscEquipmentTypes,
  ...DND5E.armorTypes
};
preLocalize("equipmentTypes", { sort: true });

/* -------------------------------------------- */

/**
 * The various types of vehicles in which characters can be proficient.
 * @enum {string}
 */
DND5E.vehicleTypes = {
  air: "DND5E.VehicleTypeAir",
  land: "DND5E.VehicleTypeLand",
  space: "DND5E.VehicleTypeSpace",
  water: "DND5E.VehicleTypeWater"
};
preLocalize("vehicleTypes", { sort: true });

/* -------------------------------------------- */

/**
 * The set of Armor Proficiencies which a character may have.
 * @type {object}
 */
DND5E.armorProficiencies = {
  lgt: "DND5E.ArmorLightProficiency",
  med: "DND5E.ArmorMediumProficiency",
  hvy: "DND5E.ArmorHeavyProficiency",
  shl: "DND5E.EquipmentShieldProficiency"
};
preLocalize("armorProficiencies");

/**
 * A mapping between `DND5E.equipmentTypes` and `DND5E.armorProficiencies` that
 * is used to determine if character has proficiency when adding an item.
 * @enum {(boolean|string)}
 */
DND5E.armorProficienciesMap = {
  natural: true,
  clothing: true,
  light: "lgt",
  medium: "med",
  heavy: "hvy",
  shield: "shl"
};

/**
 * The basic armor types in 5e. This enables specific armor proficiencies,
 * automated AC calculation in NPCs, and starting equipment.
 * @enum {string}
 */
DND5E.armorIds = {
  breastplate: "SK2HATQ4abKUlV8i",
  chainmail: "rLMflzmxpe8JGTOA",
  chainshirt: "p2zChy24ZJdVqMSH",
  halfplate: "vsgmACFYINloIdPm",
  hide: "n1V07puo0RQxPGuF",
  leather: "WwdpHLXGX5r8uZu5",
  padded: "GtKV1b5uqFQqpEni",
  plate: "OjkIqlW2UpgFcjZa",
  ringmail: "nsXZejlmgalj4he9",
  scalemail: "XmnlF5fgIO3tg6TG",
  splint: "cKpJmsJmU8YaiuqG",
  studded: "TIV3B1vbrVHIhQAm"
};

/**
 * The basic shield in 5e.
 * @enum {string}
 */
DND5E.shieldIds = {
  shield: "sSs3hSzkKBMNBgTs"
};

/**
 * Common armor class calculations.
 * @enum {{ label: string, [formula]: string }}
 */
DND5E.armorClasses = {
  flat: {
    label: "DND5E.ArmorClassFlat",
    formula: "@attributes.ac.flat"
  },
  natural: {
    label: "DND5E.ArmorClassNatural",
    formula: "@attributes.ac.flat"
  },
  default: {
    label: "DND5E.ArmorClassEquipment",
    formula: "@attributes.ac.armor + @attributes.ac.dex"
  },
  mage: {
    label: "DND5E.ArmorClassMage",
    formula: "13 + @abilities.dex.mod"
  },
  draconic: {
    label: "DND5E.ArmorClassDraconic",
    formula: "13 + @abilities.dex.mod"
  },
  unarmoredMonk: {
    label: "DND5E.ArmorClassUnarmoredMonk",
    formula: "10 + @abilities.dex.mod + @abilities.wis.mod"
  },
  unarmoredBarb: {
    label: "DND5E.ArmorClassUnarmoredBarbarian",
    formula: "10 + @abilities.dex.mod + @abilities.con.mod"
  },
  unarmoredBard: {
    label: "DND5E.ArmorClassUnarmoredBard",
    formula: "10 + @abilities.dex.mod + @abilities.cha.mod"
  },
  custom: {
    label: "DND5E.ArmorClassCustom"
  }
};
preLocalize("armorClasses", { key: "label" });

/* -------------------------------------------- */

/**
 * Configuration data for an items that have sub-types.
 *
 * @typedef {object} SubtypeTypeConfiguration
 * @property {string} label                       Localized label for this type.
 * @property {Record<string, string>} [subtypes]  Enum containing localized labels for subtypes.
 */

/**
 * Enumerate the valid consumable types which are recognized by the system.
 * @enum {SubtypeTypeConfiguration}
 */
DND5E.consumableTypes = {
  ammo: {
    label: "DND5E.CONSUMABLE.Type.Ammunition.Label",
    subtypes: {
      arrow: "DND5E.CONSUMABLE.Type.Ammunition.Arrow",
      crossbowBolt: "DND5E.CONSUMABLE.Type.Ammunition.Bolt",
      energyCell: "DND5E.CONSUMABLE.Type.Ammunition.EnergyCell",
      firearmBullet: "DND5E.CONSUMABLE.Type.Ammunition.BulletFirearm",
      slingBullet: "DND5E.CONSUMABLE.Type.Ammunition.BulletSling",
      blowgunNeedle: "DND5E.CONSUMABLE.Type.Ammunition.Needle"
    }
  },
  potion: {
    label: "DND5E.CONSUMABLE.Type.Potion.Label"
  },
  poison: {
    label: "DND5E.CONSUMABLE.Type.Poison.Label",
    subtypes: {
      contact: "DND5E.CONSUMABLE.Type.Poison.Contact",
      ingested: "DND5E.CONSUMABLE.Type.Poison.Ingested",
      inhaled: "DND5E.CONSUMABLE.Type.Poison.Inhaled",
      injury: "DND5E.CONSUMABLE.Type.Poison.Injury"
    }
  },
  food: {
    label: "DND5E.CONSUMABLE.Type.Food.Label"
  },
  scroll: {
    label: "DND5E.CONSUMABLE.Type.Scroll.Label"
  },
  wand: {
    label: "DND5E.CONSUMABLE.Type.Wand.Label"
  },
  rod: {
    label: "DND5E.CONSUMABLE.Type.Rod.Label"
  },
  trinket: {
    label: "DND5E.CONSUMABLE.Type.Trinket.Label"
  }
};
preLocalize("consumableTypes", { key: "label", sort: true });
preLocalize("consumableTypes.ammo.subtypes", { sort: true });
preLocalize("consumableTypes.poison.subtypes", { sort: true });

/* -------------------------------------------- */

/**
 * Types of containers.
 * @enum {string}
 */
DND5E.containerTypes = {
  backpack: "H8YCd689ezlD26aT",
  barrel: "7Yqbqg5EtVW16wfT",
  basket: "Wv7HzD6dv1P0q78N",
  boltcase: "eJtPBiZtr2pp6ynt",
  bottle: "HZp69hhyNZUUCipF",
  bucket: "mQVYcHmMSoCUnBnM",
  case: "5mIeX824uMklU3xq",
  chest: "2YbuclKfhDL0bU4u",
  flask: "lHS63sC6bypENNlR",
  jug: "0ZBWwjFz3nIAXMLW",
  pot: "M8xM8BLK4tpUayEE",
  pitcher: "nXWdGtzi8DXDLLsL",
  pouch: "9bWTRRDym06PzSAf",
  quiver: "4MtQKPn9qMWCFjDA",
  sack: "CNdDj8dsXVpRVpXt",
  saddlebags: "TmfaFUSZJAotndn9",
  tankard: "uw6fINSmZ2j2o57A",
  vial: "meJEfX3gZgtMX4x2"
};

/* -------------------------------------------- */

/**
 * Configuration data for spellcasting foci.
 *
 * @typedef {object} SpellcastingFocusConfiguration
 * @property {string} label                    Localized label for this category.
 * @property {Object<string, string>} itemIds  Item IDs or UUIDs.
 */

/**
 * Type of spellcasting foci.
 * @enum {SpellcastingFocusConfiguration}
 */
DND5E.focusTypes = {
  arcane: {
    label: "DND5E.Focus.Arcane",
    itemIds: {
      crystal: "uXOT4fYbgPY8DGdd",
      orb: "tH5Rn0JVRG1zdmPa",
      rod: "OojyyGfh91iViuMF",
      staff: "BeKIrNIvNHRPQ4t5",
      wand: "KA2P6I48iOWlnboO"
    }
  },
  druidic: {
    label: "DND5E.Focus.Druidic",
    itemIds: {
      mistletoe: "xDK9GQd2iqOGH8Sd",
      totem: "PGL6aaM0wE5h0VN5",
      woodenstaff: "FF1ktpb2YSiyv896",
      yewwand: "t5yP0d7YaKwuKKiH"
    }
  },
  holy: {
    label: "DND5E.Focus.Holy",
    itemIds: {
      amulet: "paqlMjggWkBIAeCe",
      emblem: "laVqttkGMW4B9654",
      reliquary: "gP1URGq3kVIIFHJ7"
    }
  }
};
preLocalize("focusTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Types of "features" items.
 * @enum {SubtypeTypeConfiguration}
 */
DND5E.featureTypes = {
  background: {
    label: "DND5E.Feature.Background"
  },
  class: {
    label: "DND5E.Feature.Class.Label",
    subtypes: {
      arcaneShot: "DND5E.Feature.Class.ArcaneShot",
      artificerInfusion: "DND5E.Feature.Class.ArtificerInfusion",
      channelDivinity: "DND5E.Feature.Class.ChannelDivinity",
      defensiveTactic: "DND5E.Feature.Class.DefensiveTactic",
      eldritchInvocation: "DND5E.Feature.Class.EldritchInvocation",
      elementalDiscipline: "DND5E.Feature.Class.ElementalDiscipline",
      fightingStyle: "DND5E.Feature.Class.FightingStyle",
      huntersPrey: "DND5E.Feature.Class.HuntersPrey",
      ki: "DND5E.Feature.Class.Ki",
      maneuver: "DND5E.Feature.Class.Maneuver",
      metamagic: "DND5E.Feature.Class.Metamagic",
      multiattack: "DND5E.Feature.Class.Multiattack",
      pact: "DND5E.Feature.Class.PactBoon",
      psionicPower: "DND5E.Feature.Class.PsionicPower",
      rune: "DND5E.Feature.Class.Rune",
      superiorHuntersDefense: "DND5E.Feature.Class.SuperiorHuntersDefense"
    }
  },
  monster: {
    label: "DND5E.Feature.Monster"
  },
  race: {
    label: "DND5E.Feature.Species"
  },
  enchantment: {
    label: "DND5E.ENCHANTMENT.Label",
    subtypes: {
      artificerInfusion: "DND5E.Feature.Class.ArtificerInfusion",
      rune: "DND5E.Feature.Class.Rune"
    }
  },
  feat: {
    label: "DND5E.Feature.Feat.Label",
    subtypes: {
      general: "DND5E.Feature.Feat.General",
      origin: "DND5E.Feature.Feat.Origin",
      fightingStyle: "DND5E.Feature.Feat.FightingStyle",
      epicBoon: "DND5E.Feature.Feat.EpicBoon"
    }
  },
  supernaturalGift: {
    label: "DND5E.Feature.SupernaturalGift.Label",
    subtypes: {
      blessing: "DND5E.Feature.SupernaturalGift.Blessing",
      charm: "DND5E.Feature.SupernaturalGift.Charm",
      epicBoon: "DND5E.Feature.SupernaturalGift.EpicBoon"
    }
  },
  vehicle: {
    label: "DND5E.Feature.Vehicle.Label"
  }
};
preLocalize("featureTypes", { key: "label" });
preLocalize("featureTypes.class.subtypes", { sort: true });
preLocalize("featureTypes.enchantment.subtypes", { sort: true });
preLocalize("featureTypes.feat.subtypes", { sort: true });
preLocalize("featureTypes.supernaturalGift.subtypes", { sort: true });

/* -------------------------------------------- */

/**
 * Configuration data for item properties.
 *
 * @typedef {object} ItemPropertyConfiguration
 * @property {string} label           Localized label.
 * @property {string} [abbreviation]  Localized abbreviation.
 * @property {string} [icon]          Icon that can be used in certain places to represent this property.
 * @property {string} [reference]     Reference to a rule page describing this property.
 * @property {boolean} [isPhysical]   Is this property one that can cause damage resistance bypasses?
 * @property {boolean} [isTag]        Is this spell property a tag, rather than a component?
 */

/**
 * The various properties of all item types.
 * @enum {ItemPropertyConfiguration}
 */
DND5E.itemProperties = {
  ada: {
    label: "DND5E.Item.Property.Adamantine",
    isPhysical: true
  },
  amm: {
    label: "DND5E.Item.Property.Ammunition"
  },
  concentration: {
    label: "DND5E.Item.Property.Concentration",
    abbreviation: "DND5E.ConcentrationAbbr",
    icon: "systems/dnd5e/icons/svg/statuses/concentrating.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ow58p27ctAnr4VPH",
    isTag: true
  },
  fin: {
    label: "DND5E.Item.Property.Finesse"
  },
  fir: {
    label: "DND5E.Item.Property.Firearm"
  },
  foc: {
    label: "DND5E.Item.Property.Focus"
  },
  hvy: {
    label: "DND5E.Item.Property.Heavy"
  },
  lgt: {
    label: "DND5E.Item.Property.Light"
  },
  lod: {
    label: "DND5E.Item.Property.Loading"
  },
  material: {
    label: "DND5E.Item.Property.Material",
    abbreviation: "DND5E.ComponentMaterialAbbr",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AeH5eDS4YeM9RETC"
  },
  mgc: {
    label: "DND5E.Item.Property.Magical",
    icon: "systems/dnd5e/icons/svg/properties/magical.svg",
    isPhysical: true
  },
  rch: {
    label: "DND5E.Item.Property.Reach"
  },
  rel: {
    label: "DND5E.Item.Property.Reload"
  },
  ret: {
    label: "DND5E.Item.Property.Returning"
  },
  ritual: {
    label: "DND5E.Item.Property.Ritual",
    abbreviation: "DND5E.RitualAbbr",
    icon: "systems/dnd5e/icons/svg/items/spell.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.FjWqT5iyJ89kohdA",
    isTag: true
  },
  sil: {
    label: "DND5E.Item.Property.Silvered",
    isPhysical: true
  },
  somatic: {
    label: "DND5E.Item.Property.Somatic",
    abbreviation: "DND5E.ComponentSomaticAbbr",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.qwUNgUNilEmZkSC9"
  },
  spc: {
    label: "DND5E.Item.Property.Special"
  },
  stealthDisadvantage: {
    label: "DND5E.Item.Property.StealthDisadvantage"
  },
  thr: {
    label: "DND5E.Item.Property.Thrown"
  },
  trait: {
    label: "DND5E.Item.Property.Trait"
  },
  two: {
    label: "DND5E.Item.Property.TwoHanded"
  },
  ver: {
    label: "DND5E.Item.Property.Versatile"
  },
  vocal: {
    label: "DND5E.Item.Property.Verbal",
    abbreviation: "DND5E.ComponentVerbalAbbr",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6UXTNWMCQ0nSlwwx"
  },
  weightlessContents: {
    label: "DND5E.Item.Property.WeightlessContents"
  }
};
preLocalize("itemProperties", { keys: ["label", "abbreviation"], sort: true });

/* -------------------------------------------- */

/**
 * The various properties of an item per item type.
 * @enum {object}
 */
DND5E.validProperties = {
  consumable: new Set([
    "mgc"
  ]),
  container: new Set([
    "mgc",
    "weightlessContents"
  ]),
  equipment: new Set([
    "ada",
    "foc",
    "mgc",
    "stealthDisadvantage"
  ]),
  feat: new Set([
    "mgc",
    "trait"
  ]),
  loot: new Set([
    "mgc"
  ]),
  weapon: new Set([
    "ada",
    "amm",
    "fin",
    "fir",
    "foc",
    "hvy",
    "lgt",
    "lod",
    "mgc",
    "rch",
    "rel",
    "ret",
    "sil",
    "spc",
    "thr",
    "two",
    "ver"
  ]),
  spell: new Set([
    "vocal",
    "somatic",
    "material",
    "concentration",
    "ritual"
  ]),
  tool: new Set([
    "mgc"
  ])
};

/* -------------------------------------------- */

/**
 * Configuration data for an item with the "loot" type.
 *
 * @typedef {object} LootTypeConfiguration
 * @property {string} label                       Localized label for this type.
 */

/**
 * Types of "loot" items.
 * @enum {LootTypeConfiguration}
 */
DND5E.lootTypes = {
  art: {
    label: "DND5E.Loot.Art"
  },
  gear: {
    label: "DND5E.Loot.Gear"
  },
  gem: {
    label: "DND5E.Loot.Gem"
  },
  junk: {
    label: "DND5E.Loot.Junk"
  },
  material: {
    label: "DND5E.Loot.Material"
  },
  resource: {
    label: "DND5E.Loot.Resource"
  },
  treasure: {
    label: "DND5E.Loot.Treasure"
  }
};
preLocalize("lootTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * @typedef {object} CurrencyConfiguration
 * @property {string} label         Localized label for the currency.
 * @property {string} abbreviation  Localized abbreviation for the currency.
 * @property {number} conversion    Number by which this currency should be multiplied to arrive at a standard value.
 * @property {string} icon          Icon representing the currency in the interface.
 */

/**
 * The valid currency denominations with localized labels, abbreviations, and conversions.
 * The conversion number defines how many of that currency are equal to one GP.
 * @enum {CurrencyConfiguration}
 */
DND5E.currencies = {
  pp: {
    label: "DND5E.CurrencyPP",
    abbreviation: "DND5E.CurrencyAbbrPP",
    conversion: 0.1,
    icon: "systems/dnd5e/icons/currency/platinum.webp"
  },
  gp: {
    label: "DND5E.CurrencyGP",
    abbreviation: "DND5E.CurrencyAbbrGP",
    conversion: 1,
    icon: "systems/dnd5e/icons/currency/gold.webp"
  },
  ep: {
    label: "DND5E.CurrencyEP",
    abbreviation: "DND5E.CurrencyAbbrEP",
    conversion: 2,
    icon: "systems/dnd5e/icons/currency/electrum.webp"
  },
  sp: {
    label: "DND5E.CurrencySP",
    abbreviation: "DND5E.CurrencyAbbrSP",
    conversion: 10,
    icon: "systems/dnd5e/icons/currency/silver.webp"
  },
  cp: {
    label: "DND5E.CurrencyCP",
    abbreviation: "DND5E.CurrencyAbbrCP",
    conversion: 100,
    icon: "systems/dnd5e/icons/currency/copper.webp"
  }
};
preLocalize("currencies", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * @typedef CraftingConfiguration
 * @property {CraftingCostsMultiplier} consumable        Discounts for crafting a magical consumable.
 * @property {Record<string, CraftingCosts>} exceptions  Crafting costs for items that are exception to the general
 *                                                       crafting rules, by identifier.
 * @property {Record<string, CraftingCosts>} magic       Magic item crafting costs by rarity.
 * @property {CraftingCostsMultiplier} mundane           Multipliers for crafting mundane items.
 * @property {Record<number, CraftingCosts>} scrolls     Crafting costs for spell scrolls by level.
 */

/**
 * @typedef CraftingCostsMultiplier
 * @property {number} days  The days multiplier.
 * @property {number} gold  The gold multiplier.
 */

/**
 * @typedef CraftingCosts
 * @property {number} days  The number of days required to craft the item, not including its base item.
 * @property {number} gold  The amount of gold required for the raw materials, not including the base item.
 */

/**
 * Configuration data for crafting costs.
 * @type {CraftingConfiguration}
 */
DND5E.crafting = {
  consumable: {
    days: .5,
    gold: .5
  },
  exceptions: {
    "potion-of-healing": {
      days: 1,
      gold: 25
    }
  },
  magic: {
    common: {
      days: 5,
      gold: 50
    },
    uncommon: {
      days: 10,
      gold: 200
    },
    rare: {
      days: 50,
      gold: 2_000
    },
    veryRare: {
      days: 125,
      gold: 20_000
    },
    legendary: {
      days: 250,
      gold: 100_000
    }
  },
  mundane: {
    days: .1,
    gold: .5
  },
  scrolls: {
    0: {
      days: 1,
      gold: 15
    },
    1: {
      days: 1,
      gold: 25
    },
    2: {
      days: 3,
      gold: 100
    },
    3: {
      days: 5,
      gold: 150
    },
    4: {
      days: 10,
      gold: 1_000
    },
    5: {
      days: 25,
      gold: 1_500
    },
    6: {
      days: 40,
      gold: 10_000
    },
    7: {
      days: 50,
      gold: 12_500
    },
    8: {
      days: 60,
      gold: 15_000
    },
    9: {
      days: 120,
      gold: 50_000
    }
  }
};

/* -------------------------------------------- */
/*  Damage                                      */
/* -------------------------------------------- */

/**
 * Standard dice spread available for things like damage.
 * @type {number[]}
 */
DND5E.dieSteps = [4, 6, 8, 10, 12, 20, 100];

/* -------------------------------------------- */

/**
 * Methods by which damage scales relative to the overall scaling increase.
 * @enum {{ label: string, labelCantrip: string }}
 */
DND5E.damageScalingModes = {
  whole: {
    label: "DND5E.DAMAGE.Scaling.Whole",
    labelCantrip: "DND5E.DAMAGE.Scaling.WholeCantrip"
  },
  half: {
    label: "DND5E.DAMAGE.Scaling.Half",
    labelCantrip: "DND5E.DAMAGE.Scaling.HalfCantrip"
  }
};
preLocalize("damageScalingModes", { keys: ["label", "labelCantrip"] });

/* -------------------------------------------- */

/**
 * Configuration data for damage types.
 *
 * @typedef {object} DamageTypeConfiguration
 * @property {string} label          Localized label.
 * @property {string} icon           Icon representing this type.
 * @property {boolean} [isPhysical]  Is this a type that can be bypassed by magical or silvered weapons?
 * @property {string} [reference]    Reference to a rule page describing this damage type.
 * @property {Color} [color]         Visual color of the damage type.
 */

/**
 * Types of damage the can be caused by abilities.
 * @enum {DamageTypeConfiguration}
 */
DND5E.damageTypes = {
  acid: {
    label: "DND5E.DamageAcid",
    icon: "systems/dnd5e/icons/svg/damage/acid.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.IQhbKRPe1vCPdh8v",
    color: new Color(0x839D50)
  },
  bludgeoning: {
    label: "DND5E.DamageBludgeoning",
    icon: "systems/dnd5e/icons/svg/damage/bludgeoning.svg",
    isPhysical: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.39LFrlef94JIYO8m",
    color: new Color(0x0000A0)
  },
  cold: {
    label: "DND5E.DamageCold",
    icon: "systems/dnd5e/icons/svg/damage/cold.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4xsFUooHDEdfhw6g",
    color: new Color(0xADD8E6)
  },
  fire: {
    label: "DND5E.DamageFire",
    icon: "systems/dnd5e/icons/svg/damage/fire.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.f1S66aQJi4PmOng6",
    color: new Color(0xFF4500)
  },
  force: {
    label: "DND5E.DamageForce",
    icon: "systems/dnd5e/icons/svg/damage/force.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eFTWzngD8dKWQuUR",
    color: new Color(0x800080)
  },
  lightning: {
    label: "DND5E.DamageLightning",
    icon: "systems/dnd5e/icons/svg/damage/lightning.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9SaxFJ9bM3SutaMC",
    color: new Color(0x1E90FF)
  },
  necrotic: {
    label: "DND5E.DamageNecrotic",
    icon: "systems/dnd5e/icons/svg/damage/necrotic.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.klOVUV5G1U7iaKoG",
    color: new Color(0x006400)
  },
  piercing: {
    label: "DND5E.DamagePiercing",
    icon: "systems/dnd5e/icons/svg/damage/piercing.svg",
    isPhysical: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.95agSnEGTdAmKhyC",
    color: new Color(0xC0C0C0)
  },
  poison: {
    label: "DND5E.DamagePoison",
    icon: "systems/dnd5e/icons/svg/damage/poison.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.k5wOYXdWPzcWwds1",
    color: new Color(0x8A2BE2)
  },
  psychic: {
    label: "DND5E.DamagePsychic",
    icon: "systems/dnd5e/icons/svg/damage/psychic.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.YIKbDv4zYqbE5teJ",
    color: new Color(0xFF1493)
  },
  radiant: {
    label: "DND5E.DamageRadiant",
    icon: "systems/dnd5e/icons/svg/damage/radiant.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5tcK9buXWDOw8yHH",
    color: new Color(0xFFD700)
  },
  slashing: {
    label: "DND5E.DamageSlashing",
    icon: "systems/dnd5e/icons/svg/damage/slashing.svg",
    isPhysical: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.sz2XKQ5lgsdPEJOa",
    color: new Color(0x8B0000)
  },
  thunder: {
    label: "DND5E.DamageThunder",
    icon: "systems/dnd5e/icons/svg/damage/thunder.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iqsmMHk7FSpiNkQy",
    color: new Color(0x708090)
  }
};
preLocalize("damageTypes", { keys: ["label"], sort: true });

/* -------------------------------------------- */

/**
 * Display aggregated damage in chat cards.
 * @type {boolean}
 */
DND5E.aggregateDamageDisplay = true;

/* -------------------------------------------- */
/*  Movement                                    */
/* -------------------------------------------- */

/**
 * Different types of healing that can be applied using abilities.
 * @enum {string}
 */
DND5E.healingTypes = {
  healing: {
    label: "DND5E.Healing",
    icon: "systems/dnd5e/icons/svg/damage/healing.svg",
    color: new Color(0x46C252)
  },
  temphp: {
    label: "DND5E.HealingTemp",
    icon: "systems/dnd5e/icons/svg/damage/temphp.svg",
    color: new Color(0x4B66DE)
  }
};
preLocalize("healingTypes", { keys: ["label"] });

/* -------------------------------------------- */

/**
 * The valid units of measure for movement distances in the game system.
 * By default this uses the imperial units of feet and miles.
 * @enum {string}
 */
DND5E.movementTypes = {
  burrow: "DND5E.MovementBurrow",
  climb: "DND5E.MovementClimb",
  fly: "DND5E.MovementFly",
  swim: "DND5E.MovementSwim",
  walk: "DND5E.MovementWalk"
};
preLocalize("movementTypes", { sort: true });

/* -------------------------------------------- */
/*  Measurement                                 */
/* -------------------------------------------- */

/**
 * Default units used for imperial & metric settings.
 * @enum {{ imperial: string, metric: string }}
 */
DND5E.defaultUnits = {
  length: {
    imperial: "ft",
    metric: "m"
  },
  volume: {
    imperial: "cubicFoot",
    metric: "liter"
  },
  weight: {
    imperial: "lb",
    metric: "kg"
  }
};

/* -------------------------------------------- */

/**
 * @typedef {object} UnitConfiguration
 * @property {string} label              Localized label for the unit.
 * @property {string} abbreviation       Localized abbreviation for the unit.
 * @property {number} conversion         Multiplier used to convert between various units.
 * @property {string} [counted]          Localization path for counted plural forms in various unit display modes.
 *                                       Only necessary if non-supported unit or using a non-standard name for a
 *                                       supported unit.
 * @property {string} [formattingUnit]   Unit formatting value as supported by javascript's internationalization system:
 *                                       https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers. Only
 *                                       required if the formatting name doesn't match the unit key.
 * @property {"imperial"|"metric"} type  Whether this is an "imperial" or "metric" unit.
 */

/**
 * The valid units of measure for movement distances in the game system.
 * By default this uses the imperial units of feet and miles.
 * @enum {string}
 */
DND5E.movementUnits = {
  ft: {
    label: "DND5E.UNITS.DISTANCE.Foot.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Foot.Abbreviation",
    conversion: 1,
    formattingUnit: "foot",
    type: "imperial"
  },
  mi: {
    label: "DND5E.UNITS.DISTANCE.Mile.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Mile.Abbreviation",
    conversion: 5_280,
    formattingUnit: "mile",
    type: "imperial"
  },
  m: {
    label: "DND5E.UNITS.DISTANCE.Meter.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Meter.Abbreviation",
    conversion: 10 / 3, // D&D uses a simplified 5ft -> 1.5m conversion.
    formattingUnit: "meter",
    type: "metric"
  },
  km: {
    label: "DND5E.UNITS.DISTANCE.Kilometer.Label",
    abbreviation: "DND5E.UNITS.DISTANCE.Kilometer.Abbreviation",
    conversion: 10_000 / 3, // Matching simplified conversion
    formattingUnit: "kilometer",
    type: "metric"
  }
};
patchConfig("movementUnits", "label", { since: "DnD5e 4.2", until: "DnD5e 4.4" });
preLocalize("movementUnits", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * The types of range that are used for measuring actions and effects.
 * @enum {string}
 */
DND5E.rangeTypes = {
  self: "DND5E.DistSelf",
  touch: "DND5E.DistTouch",
  spec: "DND5E.Special",
  any: "DND5E.DistAny"
};
preLocalize("rangeTypes");

/* -------------------------------------------- */

/**
 * The valid units of measure for the range of an action or effect. A combination of `DND5E.movementUnits` and
 * `DND5E.rangeUnits`.
 * @enum {string}
 */
DND5E.distanceUnits = {
  ...Object.fromEntries(Object.entries(DND5E.movementUnits).map(([k, { label }]) => [k, label])),
  ...DND5E.rangeTypes
};
preLocalize("distanceUnits");

/* -------------------------------------------- */

/**
 * The valid units for measurement of volume.
 * @enum {UnitConfiguration}
 */
DND5E.volumeUnits = {
  cubicFoot: {
    label: "DND5E.UNITS.VOLUME.CubicFoot.Label",
    abbreviation: "DND5E.UNITS.Volume.CubicFoot.Abbreviation",
    counted: "DND5E.UNITS.Volume.CubicFoot.Counted",
    conversion: 1,
    type: "imperial"
  },
  liter: {
    label: "DND5E.UNITS.VOLUME.Liter.Label",
    abbreviation: "DND5E.UNITS.Volume.Liter.Abbreviation",
    conversion: 1 / 28.317,
    type: "metric"
  }
};
preLocalize("volumeUnits", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * The valid units for measurement of weight.
 * @enum {UnitConfiguration}
 */
DND5E.weightUnits = {
  lb: {
    label: "DND5E.UNITS.WEIGHT.Pound.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Pound.Abbreviation",
    conversion: 1,
    formattingUnit: "pound",
    type: "imperial"
  },
  tn: {
    label: "DND5E.UNITS.WEIGHT.Ton.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Ton.Abbreviation",
    counted: "DND5E.UNITS.WEIGHT.Ton.Counted",
    conversion: 2000,
    type: "imperial"
  },
  kg: {
    label: "DND5E.UNITS.WEIGHT.Kilogram.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Kilogram.Abbreviation",
    conversion: 2.5,
    formattingUnit: "kilogram",
    type: "metric"
  },
  Mg: {
    label: "DND5E.UNITS.WEIGHT.Megagram.Label",
    abbreviation: "DND5E.UNITS.WEIGHT.Megagram.Abbreviation",
    counted: "DND5E.UNITS.WEIGHT.Megagram.Counted",
    conversion: 2500,
    type: "metric"
  }
};
preLocalize("weightUnits", { keys: ["label", "abbreviation"] });

/* -------------------------------------------- */

/**
 * Encumbrance configuration data.
 *
 * @typedef {object} EncumbranceConfiguration
 * @property {Record<string, number>} currencyPerWeight  Pieces of currency that equal a base weight (lbs or kgs).
 * @property {Record<string, object>} effects            Data used to create encumbrance-related Active Effects.
 * @property {object} threshold                          Amount to multiply strength to get given capacity threshold.
 * @property {Record<string, number>} threshold.encumbered
 * @property {Record<string, number>} threshold.heavilyEncumbered
 * @property {Record<string, number>} threshold.maximum
 * @property {Record<string, {ft: number, m: number}>} speedReduction  Speed reduction caused by encumbered status.
 * @property {Record<string, number>} vehicleWeightMultiplier  Multiplier used to determine vehicle carrying capacity.
 * @property {Record<string, Record<string, string>>} baseUnits  Base units used to calculate carrying weight.
 */

/**
 * Configure aspects of encumbrance calculation so that it could be configured by modules.
 * @type {EncumbranceConfiguration}
 */
DND5E.encumbrance = {
  currencyPerWeight: {
    imperial: 50,
    metric: 110
  },
  effects: {
    encumbered: {
      name: "EFFECT.DND5E.StatusEncumbered",
      icon: "systems/dnd5e/icons/svg/statuses/encumbered.svg"
    },
    heavilyEncumbered: {
      name: "EFFECT.DND5E.StatusHeavilyEncumbered",
      icon: "systems/dnd5e/icons/svg/statuses/heavily-encumbered.svg"
    },
    exceedingCarryingCapacity: {
      name: "EFFECT.DND5E.StatusExceedingCarryingCapacity",
      icon: "systems/dnd5e/icons/svg/statuses/exceeding-carrying-capacity.svg"
    }
  },
  threshold: {
    encumbered: {
      imperial: 5,
      metric: 2.5
    },
    heavilyEncumbered: {
      imperial: 10,
      metric: 5
    },
    maximum: {
      imperial: 15,
      metric: 7.5
    }
  },
  speedReduction: {
    encumbered: {
      ft: 10,
      m: 3
    },
    heavilyEncumbered: {
      ft: 20,
      m: 6
    },
    exceedingCarryingCapacity: {
      ft: 5,
      m: 1.5
    }
  },
  baseUnits: {
    default: {
      imperial: "lb",
      metric: "kg"
    },
    vehicle: {
      imperial: "tn",
      metric: "Mg"
    }
  }
};
preLocalize("encumbrance.effects", { key: "name" });

/* -------------------------------------------- */
/*  Targeting                                   */
/* -------------------------------------------- */

/**
 * @typedef {object} IndividualTargetDefinition
 * @property {string} label           Localized label for this type.
 * @property {string} [counted]       Localization path for counted plural forms. Only necessary for scalar types.
 * @property {boolean} [scalar=true]  Can this target take an associated numeric value?
 */

/**
 * Targeting types that apply to one or more distinct targets.
 * @enum {IndividualTargetDefinition}
 */
DND5E.individualTargetTypes = {
  self: {
    label: "DND5E.TARGET.Type.Self.Label",
    scalar: false
  },
  ally: {
    label: "DND5E.TARGET.Type.Ally.Label",
    counted: "DND5E.TARGET.Type.Ally.Counted"
  },
  enemy: {
    label: "DND5E.TARGET.Type.Enemy.Label",
    counted: "DND5E.TARGET.Type.Enemy.Counted"
  },
  creature: {
    label: "DND5E.TARGET.Type.Creature.Label",
    counted: "DND5E.TARGET.Type.Creature.Counted"
  },
  object: {
    label: "DND5E.TARGET.Type.Object.Label",
    counted: "DND5E.TARGET.Type.Object.Counted"
  },
  space: {
    label: "DND5E.TARGET.Type.Space.Label",
    counted: "DND5E.TARGET.Type.Space.Counted"
  },
  creatureOrObject: {
    label: "DND5E.TARGET.Type.CreatureOrObject.Label",
    counted: "DND5E.TARGET.Type.CreatureOrObject.Counted"
  },
  any: {
    label: "DND5E.TARGET.Type.Any.Label",
    counted: "DND5E.TARGET.Type.Target.Counted"
  },
  willing: {
    label: "DND5E.TARGET.Type.WillingCreature.Label",
    counted: "DND5E.TARGET.Type.WillingCreature.Counted"
  }
};
patchConfig("individualTargetTypes", "label", { from: "DnD5e 4.2", until: "DnD5e 4.4" });
preLocalize("individualTargetTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * Information needed to represent different area of effect target types.
 *
 * @typedef {object} AreaTargetDefinition
 * @property {string} label        Localized label for this type.
 * @property {string} counted      Localization path for counted plural forms.
 * @property {string} template     Type of `MeasuredTemplate` create for this target type.
 * @property {string} [reference]  Reference to a rule page describing this area of effect.
 * @property {string[]} [sizes]    List of available sizes for this template. Options are chosen from the list:
 *                                 "radius", "width", "height", "length", "thickness". No more than 3 dimensions may
 *                                 be specified.
 * @property {boolean} [standard]  Is this a standard area of effect as defined explicitly by the rules?
 */

/**
 * Targeting types that cover an area.
 * @enum {AreaTargetDefinition}
 */
DND5E.areaTargetTypes = {
  circle: {
    label: "DND5E.TARGET.Type.Circle.Label",
    counted: "DND5E.TARGET.Type.Circle.Counted",
    template: "circle",
    sizes: ["radius"]
  },
  cone: {
    label: "DND5E.TARGET.Type.Cone.Label",
    counted: "DND5E.TARGET.Type.Cone.Counted",
    template: "cone",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DqqAOr5JnX71OCOw",
    sizes: ["length"],
    standard: true
  },
  cube: {
    label: "DND5E.TARGET.Type.Cube.Label",
    counted: "DND5E.TARGET.Type.Cube.Counted",
    template: "rect",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.dRfDIwuaHmUQ06uA",
    sizes: ["width"],
    standard: true
  },
  cylinder: {
    label: "DND5E.TARGET.Type.Cylinder.Label",
    counted: "DND5E.TARGET.Type.Cylinder.Counted",
    template: "circle",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jZFp4R7tXsIqkiG3",
    sizes: ["radius", "height"],
    standard: true
  },
  line: {
    label: "DND5E.TARGET.Type.Line.Label",
    counted: "DND5E.TARGET.Type.Line.Counted",
    template: "ray",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6DOoBgg7okm9gBc6",
    sizes: ["length", "width"],
    standard: true
  },
  radius: {
    label: "DND5E.TARGET.Type.Emanation.Label",
    counted: "DND5E.TARGET.Type.Emanation.Counted",
    template: "circle",
    standard: true
  },
  sphere: {
    label: "DND5E.TARGET.Type.Sphere.Label",
    counted: "DND5E.TARGET.Type.Sphere.Counted",
    template: "circle",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.npdEWb2egUPnB5Fa",
    sizes: ["radius"],
    standard: true
  },
  square: {
    label: "DND5E.TARGET.Type.Square.Label",
    counted: "DND5E.TARGET.Type.Square.Counted",
    template: "rect",
    sizes: ["width"]
  },
  wall: {
    label: "DND5E.TARGET.Type.Wall.Label",
    counted: "DND5E.TARGET.Type.Wall.Counted",
    template: "ray",
    sizes: ["length", "thickness", "height"]
  }
};
preLocalize("areaTargetTypes", { key: "label", sort: true });

Object.defineProperty(DND5E, "areaTargetOptions", {
  get() {
    const { primary, secondary } = Object.entries(this.areaTargetTypes).reduce((obj, [value, data]) => {
      const entry = { value, label: data.label };
      if ( data.standard ) obj.primary.push(entry);
      else obj.secondary.push(entry);
      return obj;
    }, { primary: [], secondary: [] });
    return [{ value: "", label: "" }, ...primary, { rule: true }, ...secondary];
  }
});

/* -------------------------------------------- */

/**
 * The types of single or area targets which can be applied to abilities.
 * @enum {string}
 */
DND5E.targetTypes = {
  ...Object.fromEntries(Object.entries(DND5E.individualTargetTypes).map(([k, v]) => [k, v.label])),
  ...Object.fromEntries(Object.entries(DND5E.areaTargetTypes).map(([k, v]) => [k, v.label]))
};
preLocalize("targetTypes", { sort: true });

/* -------------------------------------------- */

/**
 * Denominations of hit dice which can apply to classes.
 * @type {string[]}
 */
DND5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12"];

/* -------------------------------------------- */

/**
 * Configuration data for rest types.
 *
 * @typedef {object} RestConfiguration
 * @property {Record<string, number>} duration      Duration of different rest variants in minutes.
 * @property {string} label                         Localized label for the rest type.
 * @property {string[]} [activationPeriods]         Activation types that should be displayed in the chat card.
 * @property {boolean} [recoverHitDice]             Should hit dice be recovered during this rest?
 * @property {boolean} [recoverHitPoints]           Should hit points be recovered during this rest?
 * @property {string[]} [recoverPeriods]            What recovery periods should be applied when this rest is taken. The
 *                                                  ordering of the periods determines which is applied if more than one
 *                                                  recovery profile is found.
 * @property {Set<string>} [recoverSpellSlotTypes]  Types of spellcasting slots to recover during this rest.
 */

/**
 * Types of rests.
 * @enum {RestConfiguration}
 */
DND5E.restTypes = {
  short: {
    duration: {
      normal: 60,
      gritty: 480,
      epic: 1
    },
    label: "DND5E.REST.Short.Label",
    activationPeriods: ["shortRest"],
    recoverPeriods: ["sr"],
    recoverSpellSlotTypes: new Set(["pact"])
  },
  long: {
    duration: {
      normal: 480,
      gritty: 10_080,
      epic: 60
    },
    label: "DND5E.REST.Long.Label",
    activationPeriods: ["longRest"],
    recoverHitDice: true,
    recoverHitPoints: true,
    recoverPeriods: ["lr", "sr"],
    recoverSpellSlotTypes: new Set(["leveled", "pact"])
  }
};
preLocalize("restTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * The set of possible sensory perception types which an Actor may have.
 * @enum {string}
 */
DND5E.senses = {
  blindsight: "DND5E.SenseBlindsight",
  darkvision: "DND5E.SenseDarkvision",
  tremorsense: "DND5E.SenseTremorsense",
  truesight: "DND5E.SenseTruesight"
};
preLocalize("senses", { sort: true });

/* -------------------------------------------- */
/*  Attacks                                     */
/* -------------------------------------------- */

/**
 * Classifications of attacks based on what is performing them.
 * @enum {{ label: string }}
 */
DND5E.attackClassifications = {
  weapon: {
    label: "DND5E.ATTACK.Classification.Weapon"
  },
  spell: {
    label: "DND5E.ATTACK.Classification.Spell"
  },
  unarmed: {
    label: "DND5E.ATTACK.Classification.Unarmed"
  }
};
preLocalize("attackClassifications", { key: "label" });

/* -------------------------------------------- */

/**
 * Attack modes available for weapons.
 * @enum {string}
 */
DND5E.attackModes = Object.seal({
  oneHanded: {
    label: "DND5E.ATTACK.Mode.OneHanded"
  },
  twoHanded: {
    label: "DND5E.ATTACK.Mode.TwoHanded"
  },
  offhand: {
    label: "DND5E.ATTACK.Mode.Offhand"
  },
  ranged: {
    label: "DND5E.ATTACK.Mode.Ranged"
  },
  thrown: {
    label: "DND5E.ATTACK.Mode.Thrown"
  },
  "thrown-offhand": {
    label: "DND5E.ATTACK.Mode.ThrownOffhand"
  }
});
preLocalize("attackModes", { key: "label" });

/* -------------------------------------------- */

/**
 * Types of attacks based on range.
 * @enum {{ label: string }}
 */
DND5E.attackTypes = Object.seal({
  melee: {
    label: "DND5E.ATTACK.Type.Melee"
  },
  ranged: {
    label: "DND5E.ATTACK.Type.Ranged"
  }
});
preLocalize("attackTypes", { key: "label" });

/* -------------------------------------------- */
/*  Spellcasting                                */
/* -------------------------------------------- */

/**
 * Define the standard slot progression by character level.
 * The entries of this array represent the spell slot progression for a full spell-caster.
 * @type {number[][]}
 */
DND5E.SPELL_SLOT_TABLE = [
  [2],
  [3],
  [4, 2],
  [4, 3],
  [4, 3, 2],
  [4, 3, 3],
  [4, 3, 3, 1],
  [4, 3, 3, 2],
  [4, 3, 3, 3, 1],
  [4, 3, 3, 3, 2],
  [4, 3, 3, 3, 2, 1],
  [4, 3, 3, 3, 2, 1],
  [4, 3, 3, 3, 2, 1, 1],
  [4, 3, 3, 3, 2, 1, 1],
  [4, 3, 3, 3, 2, 1, 1, 1],
  [4, 3, 3, 3, 2, 1, 1, 1],
  [4, 3, 3, 3, 2, 1, 1, 1, 1],
  [4, 3, 3, 3, 3, 1, 1, 1, 1],
  [4, 3, 3, 3, 3, 2, 1, 1, 1],
  [4, 3, 3, 3, 3, 2, 2, 1, 1]
];

/* -------------------------------------------- */

/**
 * Configuration data for pact casting progression.
 *
 * @typedef {object} PactProgressionConfig
 * @property {number} slots  Number of spell slots granted.
 * @property {number} level  Level of spells that can be cast.
 */

/**
 * Define the pact slot & level progression by pact caster level.
 * @enum {PactProgressionConfig}
 */
DND5E.pactCastingProgression = {
  1: { slots: 1, level: 1 },
  2: { slots: 2, level: 1 },
  3: { slots: 2, level: 2 },
  5: { slots: 2, level: 3 },
  7: { slots: 2, level: 4 },
  9: { slots: 2, level: 5 },
  11: { slots: 3, level: 5 },
  17: { slots: 4, level: 5 }
};

/* -------------------------------------------- */

/**
 * Configuration data for spell preparation modes.
 *
 * @typedef {object} SpellPreparationModeConfiguration
 * @property {string} label           Localized name of this spell preparation type.
 * @property {boolean} [upcast]       Whether this preparation mode allows for upcasting.
 * @property {boolean} [cantrips]     Whether this mode allows for cantrips in a spellbook.
 * @property {number} [order]         The sort order of this mode in a spellbook.
 * @property {boolean} [prepares]     Whether this preparation mode prepares spells.
 */

/**
 * Various different ways a spell can be prepared.
 * @enum {SpellPreparationModeConfiguration}
 */
DND5E.spellPreparationModes = {
  prepared: {
    label: "DND5E.SpellPrepPrepared",
    upcast: true,
    prepares: true
  },
  pact: {
    label: "DND5E.PactMagic",
    upcast: true,
    cantrips: true,
    order: 0.5
  },
  always: {
    label: "DND5E.SpellPrepAlways",
    upcast: true,
    prepares: true
  },
  atwill: {
    label: "DND5E.SpellPrepAtWill",
    order: -30
  },
  innate: {
    label: "DND5E.SpellPrepInnate",
    order: -20
  },
  ritual: {
    label: "DND5E.SpellPrepRitual",
    order: -10
  }
};
preLocalize("spellPreparationModes", { key: "label" });

/* -------------------------------------------- */

/**
 * Configuration data for different types of spellcasting supported.
 *
 * @typedef {object} SpellcastingTypeConfiguration
 * @property {string} label                               Localized label.
 * @property {string} img                                 Image used when rendered as a favorite on the sheet.
 * @property {boolean} [shortRest]                        Are these spell slots additionally restored on a short rest?
 * @property {Object<string, SpellcastingProgressionConfiguration>} [progression]  Any progression modes for this type.
 */

/**
 * Configuration data for a spellcasting progression mode.
 *
 * @typedef {object} SpellcastingProgressionConfiguration
 * @property {string} label             Localized label.
 * @property {number} [divisor=1]       Value by which the class levels are divided to determine spellcasting level.
 * @property {boolean} [roundUp=false]  Should fractional values should be rounded up by default?
 */

/**
 * Different spellcasting types and their progression.
 * @type {SpellcastingTypeConfiguration}
 */
DND5E.spellcastingTypes = {
  leveled: {
    label: "DND5E.SpellProgLeveled",
    img: "systems/dnd5e/icons/spell-tiers/{id}.webp",
    progression: {
      full: {
        label: "DND5E.SpellProgFull",
        divisor: 1
      },
      half: {
        label: "DND5E.SpellProgHalf",
        divisor: 2,
        roundUp: true
      },
      third: {
        label: "DND5E.SpellProgThird",
        divisor: 3
      },
      artificer: {
        label: "DND5E.SpellProgArt",
        divisor: 2,
        roundUp: true
      }
    }
  },
  pact: {
    label: "DND5E.SpellProgPact",
    img: "icons/magic/unholy/silhouette-robe-evil-power.webp",
    shortRest: true
  }
};
preLocalize("spellcastingTypes", { key: "label", sort: true });
preLocalize("spellcastingTypes.leveled.progression", { key: "label" });

/* -------------------------------------------- */

/**
 * Ways in which a class can contribute to spellcasting levels.
 * @enum {string}
 */
DND5E.spellProgression = {
  none: "DND5E.SpellNone",
  full: "DND5E.SpellProgFull",
  half: "DND5E.SpellProgHalf",
  third: "DND5E.SpellProgThird",
  pact: "DND5E.SpellProgPact",
  artificer: "DND5E.SpellProgArt"
};
preLocalize("spellProgression", { key: "label" });

/* -------------------------------------------- */

/**
 * Valid spell levels.
 * @enum {string}
 */
DND5E.spellLevels = {
  0: "DND5E.SpellLevel0",
  1: "DND5E.SpellLevel1",
  2: "DND5E.SpellLevel2",
  3: "DND5E.SpellLevel3",
  4: "DND5E.SpellLevel4",
  5: "DND5E.SpellLevel5",
  6: "DND5E.SpellLevel6",
  7: "DND5E.SpellLevel7",
  8: "DND5E.SpellLevel8",
  9: "DND5E.SpellLevel9"
};
preLocalize("spellLevels");

/* -------------------------------------------- */

/**
 * The available choices for how spell damage scaling may be computed.
 * @enum {string}
 */
DND5E.spellScalingModes = {
  none: "DND5E.SpellNone",
  cantrip: "DND5E.SpellCantrip",
  level: "DND5E.SpellLevel"
};
preLocalize("spellScalingModes", { sort: true });

/* -------------------------------------------- */

/**
 * Configuration data for spell schools.
 *
 * @typedef {object} SpellSchoolConfiguration
 * @property {string} label        Localized label.
 * @property {string} icon         Spell school icon.
 * @property {string} fullKey      Fully written key used as alternate for enrichers.
 * @property {string} [reference]  Reference to a rule page describing this school.
 */

/**
 * Schools to which a spell can belong.
 * @enum {SpellSchoolConfiguration}
 */
DND5E.spellSchools = {
  abj: {
    label: "DND5E.SchoolAbj",
    icon: "systems/dnd5e/icons/svg/schools/abjuration.svg",
    fullKey: "abjuration",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.849AYEWw9FHD6JNz"
  },
  con: {
    label: "DND5E.SchoolCon",
    icon: "systems/dnd5e/icons/svg/schools/conjuration.svg",
    fullKey: "conjuration",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TWyKMhZJZGqQ6uls"
  },
  div: {
    label: "DND5E.SchoolDiv",
    icon: "systems/dnd5e/icons/svg/schools/divination.svg",
    fullKey: "divination",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HoD2MwzmVbMqj9se"
  },
  enc: {
    label: "DND5E.SchoolEnc",
    icon: "systems/dnd5e/icons/svg/schools/enchantment.svg",
    fullKey: "enchantment",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.SehPXk24ySBVOwCZ"
  },
  evo: {
    label: "DND5E.SchoolEvo",
    icon: "systems/dnd5e/icons/svg/schools/evocation.svg",
    fullKey: "evocation",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kGp1RNuxL2SELLRC"
  },
  ill: {
    label: "DND5E.SchoolIll",
    icon: "systems/dnd5e/icons/svg/schools/illusion.svg",
    fullKey: "illusion",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.smEk7kvVyslFozrB"
  },
  nec: {
    label: "DND5E.SchoolNec",
    icon: "systems/dnd5e/icons/svg/schools/necromancy.svg",
    fullKey: "necromancy",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.W0eyiV1FBmngb6Qh"
  },
  trs: {
    label: "DND5E.SchoolTrs",
    icon: "systems/dnd5e/icons/svg/schools/transmutation.svg",
    fullKey: "transmutation",
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.IYWewSailtmv6qEb"
  }
};
preLocalize("spellSchools", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * Types of spell lists.
 * @enum {string}
 */
DND5E.spellListTypes = {
  class: "TYPES.Item.class",
  subclass: "TYPES.Item.subclass",
  background: "TYPES.Item.background",
  race: "TYPES.Item.race",
  other: "JOURNALENTRYPAGE.DND5E.SpellList.Type.Other"
};
preLocalize("spellListTypes");

/* -------------------------------------------- */

/**
 * Spell scroll item ID within the `DND5E.sourcePacks` compendium or a full UUID for each spell level.
 * @enum {string}
 */
DND5E.spellScrollIds = {
  0: "rQ6sO7HDWzqMhSI3",
  1: "9GSfMg0VOA2b4uFN",
  2: "XdDp6CKh9qEvPTuS",
  3: "hqVKZie7x9w3Kqds",
  4: "DM7hzgL836ZyUFB1",
  5: "wa1VF8TXHmkrrR35",
  6: "tI3rWx4bxefNCexS",
  7: "mtyw4NS1s7j2EJaD",
  8: "aOrinPg7yuDZEuWr",
  9: "O4YbkJkLlnsgUszZ"
};

/* -------------------------------------------- */

/**
 * @typedef {object} SpellScrollValues
 * @property {number} bonus  Attack to hit bonus.
 * @property {number} dc     Saving throw DC.
 */

/**
 * Spell scroll save DCs and attack bonus values based on spell level. If matching level isn't found,
 * then the nearest level lower than it will be selected.
 * @enum {SpellScrollValues}
 */
DND5E.spellScrollValues = {
  0: { dc: 13, bonus: 5 },
  3: { dc: 15, bonus: 7 },
  5: { dc: 17, bonus: 9 },
  7: { dc: 18, bonus: 10 },
  9: { dc: 19, bonus: 11 }
};

/* -------------------------------------------- */

/**
 * Compendium packs used for localized items.
 * @enum {string}
 */
DND5E.sourcePacks = {
  BACKGROUNDS: "dnd5e.backgrounds",
  CLASSES: "dnd5e.classes",
  ITEMS: "dnd5e.items",
  RACES: "dnd5e.races"
};

/* -------------------------------------------- */

/**
 * Settings to configure how actors are merged when polymorphing is applied.
 * @enum {string}
 */
DND5E.polymorphSettings = {
  keepPhysical: "DND5E.PolymorphKeepPhysical",
  keepMental: "DND5E.PolymorphKeepMental",
  keepSaves: "DND5E.PolymorphKeepSaves",
  keepSkills: "DND5E.PolymorphKeepSkills",
  mergeSaves: "DND5E.PolymorphMergeSaves",
  mergeSkills: "DND5E.PolymorphMergeSkills",
  keepClass: "DND5E.PolymorphKeepClass",
  keepFeats: "DND5E.PolymorphKeepFeats",
  keepSpells: "DND5E.PolymorphKeepSpells",
  keepItems: "DND5E.PolymorphKeepItems",
  keepBio: "DND5E.PolymorphKeepBio",
  keepVision: "DND5E.PolymorphKeepVision",
  keepSelf: "DND5E.PolymorphKeepSelf",
  keepType: "DND5E.PolymorphKeepType",
  keepHP: "DND5E.PolymorphKeepHP",
  addTemp: "DND5E.PolymorphAddTemp"
};
preLocalize("polymorphSettings", { sort: true });

/**
 * Settings to configure how actors are effects are merged when polymorphing is applied.
 * @enum {string}
 */
DND5E.polymorphEffectSettings = {
  keepAE: "DND5E.PolymorphKeepAE",
  keepOtherOriginAE: "DND5E.PolymorphKeepOtherOriginAE",
  keepOriginAE: "DND5E.PolymorphKeepOriginAE",
  keepEquipmentAE: "DND5E.PolymorphKeepEquipmentAE",
  keepFeatAE: "DND5E.PolymorphKeepFeatureAE",
  keepSpellAE: "DND5E.PolymorphKeepSpellAE",
  keepClassAE: "DND5E.PolymorphKeepClassAE",
  keepBackgroundAE: "DND5E.PolymorphKeepBackgroundAE"
};
preLocalize("polymorphEffectSettings", { sort: true });

/**
 * Settings to configure how actors are merged when preset polymorphing is applied.
 * @enum {object}
 */
DND5E.transformationPresets = {
  wildshape: {
    icon: '<i class="fas fa-paw"></i>',
    label: "DND5E.PolymorphWildShape",
    options: {
      keepBio: true,
      keepClass: true,
      keepFeats: true,
      keepHP: true,
      keepMental: true,
      keepType: true,
      mergeSaves: true,
      mergeSkills: true,
      keepEquipmentAE: false,
      preset: "wildshape"
    }
  },
  polymorph: {
    icon: '<i class="fas fa-pastafarianism"></i>',
    label: "DND5E.Polymorph",
    options: {
      addTemp: true,
      keepHP: true,
      keepType: true,
      keepEquipmentAE: false,
      keepClassAE: false,
      keepFeatAE: false,
      keepBackgroundAE: false,
      preset: "polymorph"
    }
  },
  polymorphSelf: {
    icon: '<i class="fas fa-eye"></i>',
    label: "DND5E.PolymorphSelf",
    options: {
      keepSelf: true,
      preset: "polymorphSelf"
    }
  }
};
preLocalize("transformationPresets", { sort: true, keys: ["label"] });

/* -------------------------------------------- */

/**
 * Skill, ability, and tool proficiency levels.
 * The key for each level represents its proficiency multiplier.
 * @enum {string}
 */
DND5E.proficiencyLevels = {
  0: "DND5E.NotProficient",
  1: "DND5E.Proficient",
  0.5: "DND5E.HalfProficient",
  2: "DND5E.Expertise"
};
preLocalize("proficiencyLevels");

/* -------------------------------------------- */

/**
 * Weapon and armor item proficiency levels.
 * @enum {string}
 */
DND5E.weaponAndArmorProficiencyLevels = {
  0: "DND5E.NotProficient",
  1: "DND5E.Proficient"
};
preLocalize("weaponAndArmorProficiencyLevels");

/* -------------------------------------------- */

/**
 * The amount of cover provided by an object. In cases where multiple pieces
 * of cover are in play, we take the highest value.
 * @enum {string}
 */
DND5E.cover = {
  0: "DND5E.None",
  .5: "DND5E.CoverHalf",
  .75: "DND5E.CoverThreeQuarters",
  1: "DND5E.CoverTotal"
};
preLocalize("cover");

/* -------------------------------------------- */

/**
 * A selection of actor attributes that can be tracked on token resource bars.
 * @type {string[]}
 * @deprecated since v10
 */
DND5E.trackableAttributes = [
  "attributes.ac.value", "attributes.init.bonus", "attributes.movement", "attributes.senses",
  "attributes.spell.attack", "attributes.spell.dc", "attributes.spell.level", "details.cr",
  "details.xp.value", "skills.*.passive", "abilities.*.value"
];

/* -------------------------------------------- */

/**
 * A selection of actor and item attributes that are valid targets for item resource consumption.
 * @type {string[]}
 */
DND5E.consumableResources = [
  // Configured during init.
];

/* -------------------------------------------- */

/**
 * @typedef {object} _StatusEffectConfig5e
 * @property {string} icon              Icon used to represent the condition on the token.
 * @property {number} [order]           Order status to the start of the token HUD, rather than alphabetically.
 * @property {string} [reference]       UUID of a journal entry with details on this condition.
 * @property {string} [special]         Set this condition as a special status effect under this name.
 * @property {string[]} [riders]        Additional conditions, by id, to apply as part of this condition.
 * @property {string} [exclusiveGroup]  Any status effects with the same group will not be able to be applied at the
 *                                      same time through the token HUD (multiple statuses applied through other
 *                                      effects can still coexist).
 * @property {number} [coverBonus]      A bonus this condition provides to AC and dexterity saving throws.
 */

/**
 * Configuration data for system status effects.
 * @typedef {Omit<StatusEffectConfig, "img"> & _StatusEffectConfig5e} StatusEffectConfig5e
 */

/**
 * @typedef {object} _ConditionConfiguration
 * @property {string} label        Localized label for the condition.
 * @property {boolean} [pseudo]    Is this a pseudo-condition, i.e. one that does not appear in the conditions appendix
 *                                 but acts as a status effect?
 * @property {number} [levels]     The number of levels of exhaustion an actor can obtain.
 * @property {{ rolls: number, speed: number }} [reduction]  Amount D20 Tests & Speed are reduced per exhaustion level
 *                                                           when using the modern rules. Speed reduction is measured
 *                                                           in the default imperial units and converted to metric
 *                                                           if necessary.
 */

/**
 * Configuration data for system conditions.
 * @typedef {Omit<StatusEffectConfig5e, "name"> & _ConditionConfiguration} ConditionConfiguration
 */

/**
 * Conditions that can affect an actor.
 * @enum {ConditionConfiguration}
 */
DND5E.conditionTypes = {
  bleeding: {
    label: "EFFECT.DND5E.StatusBleeding",
    icon: "systems/dnd5e/icons/svg/statuses/bleeding.svg",
    pseudo: true
  },
  blinded: {
    label: "DND5E.ConBlinded",
    icon: "systems/dnd5e/icons/svg/statuses/blinded.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.0b8N4FymGGfbZGpJ",
    special: "BLIND"
  },
  burning: {
    label: "EFFECT.DND5E.StatusBurning",
    icon: "systems/dnd5e/icons/svg/statuses/burning.svg",
    pseudo: true
  },
  charmed: {
    label: "DND5E.ConCharmed",
    icon: "systems/dnd5e/icons/svg/statuses/charmed.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.zZaEBrKkr66OWJvD"
  },
  cursed: {
    label: "EFFECT.DND5E.StatusCursed",
    icon: "systems/dnd5e/icons/svg/statuses/cursed.svg",
    pseudo: true
  },
  dehydration: {
    label: "EFFECT.DND5E.StatusDehydration",
    icon: "systems/dnd5e/icons/svg/statuses/dehydration.svg",
    pseudo: true
  },
  deafened: {
    label: "DND5E.ConDeafened",
    icon: "systems/dnd5e/icons/svg/statuses/deafened.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.6G8JSjhn701cBITY"
  },
  diseased: {
    label: "DND5E.ConDiseased",
    icon: "systems/dnd5e/icons/svg/statuses/diseased.svg",
    pseudo: true,
    reference: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oNQWvyRZkTOJ8PBq"
  },
  exhaustion: {
    label: "DND5E.ConExhaustion",
    icon: "systems/dnd5e/icons/svg/statuses/exhaustion.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.cspWveykstnu3Zcv",
    levels: 6,
    reduction: { rolls: 2, speed: 5 }
  },
  falling: {
    label: "EFFECT.DND5E.StatusFalling",
    icon: "systems/dnd5e/icons/svg/statuses/falling.svg",
    pseudo: true
  },
  frightened: {
    label: "DND5E.ConFrightened",
    icon: "systems/dnd5e/icons/svg/statuses/frightened.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.oreoyaFKnvZCrgij"
  },
  grappled: {
    label: "DND5E.ConGrappled",
    icon: "systems/dnd5e/icons/svg/statuses/grappled.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.gYDAhd02ryUmtwZn"
  },
  incapacitated: {
    label: "DND5E.ConIncapacitated",
    icon: "systems/dnd5e/icons/svg/statuses/incapacitated.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.TpkZgLfxCmSndmpb"
  },
  invisible: {
    label: "DND5E.ConInvisible",
    icon: "systems/dnd5e/icons/svg/statuses/invisible.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.3UU5GCTVeRDbZy9u"
  },
  malnutrition: {
    label: "EFFECT.DND5E.StatusMalnutrition",
    icon: "systems/dnd5e/icons/svg/statuses/malnutrition.svg",
    pseudo: true
  },
  paralyzed: {
    label: "DND5E.ConParalyzed",
    icon: "systems/dnd5e/icons/svg/statuses/paralyzed.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.xnSV5hLJIMaTABXP",
    statuses: ["incapacitated"]
  },
  petrified: {
    label: "DND5E.ConPetrified",
    icon: "systems/dnd5e/icons/svg/statuses/petrified.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.xaNDaW6NwQTgHSmi",
    statuses: ["incapacitated"]
  },
  poisoned: {
    label: "DND5E.ConPoisoned",
    icon: "systems/dnd5e/icons/svg/statuses/poisoned.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.lq3TRI6ZlED8ABMx"
  },
  prone: {
    label: "DND5E.ConProne",
    icon: "systems/dnd5e/icons/svg/statuses/prone.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.y0TkcdyoZlOTmAFT"
  },
  restrained: {
    label: "DND5E.ConRestrained",
    icon: "systems/dnd5e/icons/svg/statuses/restrained.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.cSVcyZyNe2iG1fIc"
  },
  silenced: {
    label: "EFFECT.DND5E.StatusSilenced",
    icon: "systems/dnd5e/icons/svg/statuses/silenced.svg",
    pseudo: true
  },
  stunned: {
    label: "DND5E.ConStunned",
    icon: "systems/dnd5e/icons/svg/statuses/stunned.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.ZyZMUwA2rboh4ObS",
    statuses: ["incapacitated"]
  },
  suffocation: {
    label: "EFFECT.DND5E.StatusSuffocation",
    icon: "systems/dnd5e/icons/svg/statuses/suffocation.svg",
    pseudo: true
  },
  surprised: {
    label: "EFFECT.DND5E.StatusSurprised",
    icon: "systems/dnd5e/icons/svg/statuses/surprised.svg",
    pseudo: true
  },
  transformed: {
    label: "EFFECT.DND5E.StatusTransformed",
    icon: "systems/dnd5e/icons/svg/statuses/transformed.svg",
    pseudo: true
  },
  unconscious: {
    label: "DND5E.ConUnconscious",
    icon: "systems/dnd5e/icons/svg/statuses/unconscious.svg",
    reference: "Compendium.dnd5e.rules.JournalEntry.w7eitkpD7QQTB6j0.JournalEntryPage.UWw13ISmMxDzmwbd",
    statuses: ["incapacitated"],
    riders: ["prone"]
  }
};
preLocalize("conditionTypes", { key: "label", sort: true });

/* -------------------------------------------- */

/**
 * Various effects of conditions and which conditions apply it. Either keys for the conditions,
 * and with a number appended for a level of exhaustion.
 * @enum {object}
 */
DND5E.conditionEffects = {
  noMovement: new Set(["exhaustion-5", "grappled", "paralyzed", "petrified", "restrained", "unconscious"]),
  halfMovement: new Set(["exhaustion-2"]),
  crawl: new Set(["prone", "exceedingCarryingCapacity"]),
  petrification: new Set(["petrified"]),
  halfHealth: new Set(["exhaustion-4"])
};

/* -------------------------------------------- */

/**
 * Extra status effects not specified in `conditionTypes`. If the ID matches a core-provided effect, then this
 * data will be merged into the core data.
 * @enum {Omit<StatusEffectConfig5e, "img"> & { icon: string }}
 */
DND5E.statusEffects = {
  burrowing: {
    name: "EFFECT.DND5E.StatusBurrowing",
    icon: "systems/dnd5e/icons/svg/statuses/burrowing.svg",
    special: "BURROW"
  },
  concentrating: {
    name: "EFFECT.DND5E.StatusConcentrating",
    icon: "systems/dnd5e/icons/svg/statuses/concentrating.svg",
    special: "CONCENTRATING"
  },
  coverHalf: {
    name: "EFFECT.DND5E.StatusHalfCover",
    icon: "systems/dnd5e/icons/svg/statuses/cover-half.svg",
    order: 2,
    exclusiveGroup: "cover",
    coverBonus: 2
  },
  coverThreeQuarters: {
    name: "EFFECT.DND5E.StatusThreeQuartersCover",
    icon: "systems/dnd5e/icons/svg/statuses/cover-three-quarters.svg",
    order: 3,
    exclusiveGroup: "cover",
    coverBonus: 5
  },
  coverTotal: {
    name: "EFFECT.DND5E.StatusTotalCover",
    icon: "systems/dnd5e/icons/svg/statuses/cover-total.svg",
    order: 4,
    exclusiveGroup: "cover"
  },
  dead: {
    name: "EFFECT.DND5E.StatusDead",
    icon: "systems/dnd5e/icons/svg/statuses/dead.svg",
    special: "DEFEATED",
    order: 1
  },
  dodging: {
    name: "EFFECT.DND5E.StatusDodging",
    icon: "systems/dnd5e/icons/svg/statuses/dodging.svg"
  },
  ethereal: {
    name: "EFFECT.DND5E.StatusEthereal",
    icon: "systems/dnd5e/icons/svg/statuses/ethereal.svg"
  },
  flying: {
    name: "EFFECT.DND5E.StatusFlying",
    icon: "systems/dnd5e/icons/svg/statuses/flying.svg",
    special: "FLY"
  },
  hiding: {
    name: "EFFECT.DND5E.StatusHiding",
    icon: "systems/dnd5e/icons/svg/statuses/hiding.svg"
  },
  hovering: {
    name: "EFFECT.DND5E.StatusHovering",
    icon: "systems/dnd5e/icons/svg/statuses/hovering.svg",
    special: "HOVER"
  },
  marked: {
    name: "EFFECT.DND5E.StatusMarked",
    icon: "systems/dnd5e/icons/svg/statuses/marked.svg"
  },
  sleeping: {
    name: "EFFECT.DND5E.StatusSleeping",
    icon: "systems/dnd5e/icons/svg/statuses/sleeping.svg",
    statuses: ["incapacitated", "unconscious"]
  },
  stable: {
    name: "EFFECT.DND5E.StatusStable",
    icon: "systems/dnd5e/icons/svg/statuses/stable.svg"
  }
};

/* -------------------------------------------- */

/**
 * Configuration for the special bloodied status effect.
 * @type {{ name: string, icon: string, threshold: number }}
 */
DND5E.bloodied = {
  name: "EFFECT.DND5E.StatusBloodied",
  icon: "systems/dnd5e/icons/svg/statuses/bloodied.svg",
  threshold: .5
};

/* -------------------------------------------- */
/*  Languages                                   */
/* -------------------------------------------- */

/**
 * Languages a character can learn.
 * @enum {object}
 */
DND5E.languages = {
  standard: {
    label: "DND5E.Language.Category.Standard",
    selectable: false,
    children: {
      common: "DND5E.Language.Language.Common",
      draconic: "DND5E.Language.Language.Draconic",
      dwarvish: "DND5E.Language.Language.Dwarvish",
      elvish: "DND5E.Language.Language.Elvish",
      giant: "DND5E.Language.Language.Giant",
      gnomish: "DND5E.Language.Language.Gnomish",
      goblin: "DND5E.Language.Language.Goblin",
      halfling: "DND5E.Language.Language.Halfling",
      orc: "DND5E.Language.Language.Orc",
      sign: "DND5E.Language.Language.CommonSign"
    }
  },
  exotic: {
    label: "DND5E.Language.Category.Rare",
    selectable: false,
    children: {
      aarakocra: "DND5E.Language.Language.Aarakocra",
      abyssal: "DND5E.Language.Language.Abyssal",
      cant: "DND5E.Language.Language.ThievesCant",
      celestial: "DND5E.Language.Language.Celestial",
      deep: "DND5E.Language.Language.DeepSpeech",
      druidic: "DND5E.Language.Language.Druidic",
      gith: "DND5E.Language.Language.Gith",
      gnoll: "DND5E.Language.Language.Gnoll",
      infernal: "DND5E.Language.Language.Infernal",
      primordial: {
        label: "DND5E.Language.Language.Primordial",
        children: {
          aquan: "DND5E.Language.Language.Aquan",
          auran: "DND5E.Language.Language.Auran",
          ignan: "DND5E.Language.Language.Ignan",
          terran: "DND5E.Language.Language.Terran"
        }
      },
      sylvan: "DND5E.Language.Language.Sylvan",
      undercommon: "DND5E.Language.Language.Undercommon"
    }
  }
};
preLocalize("languages", { key: "label" });
preLocalize("languages.standard.children", { key: "label", sort: true });
preLocalize("languages.exotic.children", { key: "label", sort: true });
preLocalize("languages.exotic.children.primordial.children", { sort: true });

/* -------------------------------------------- */

/**
 * Communication types that take ranges such as telepathy.
 * @enum {{ label: string }}
 */
DND5E.communicationTypes = {
  telepathy: {
    label: "DND5E.Language.Communication.Telepathy"
  }
};
preLocalize("communicationTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * @typedef HabitatConfiguration5e
 * @property {string} label        The human-readable habitat name.
 * @property {boolean} [subtypes]  Whether this habitat is divided into sub-types.
 */

/**
 * NPC habitats.
 * @type {Record<string, HabitatConfiguration5e>}
 */
DND5E.habitats = {
  any: {
    label: "DND5E.Habitat.Categories.Any"
  },
  arctic: {
    label: "DND5E.Habitat.Categories.Arctic"
  },
  coastal: {
    label: "DND5E.Habitat.Categories.Coastal"
  },
  desert: {
    label: "DND5E.Habitat.Categories.Desert"
  },
  forest: {
    label: "DND5E.Habitat.Categories.Forest"
  },
  grassland: {
    label: "DND5E.Habitat.Categories.Grassland"
  },
  hill: {
    label: "DND5E.Habitat.Categories.Hill"
  },
  mountain: {
    label: "DND5E.Habitat.Categories.Mountain"
  },
  planar: {
    label: "DND5E.Habitat.Categories.Planar",
    subtypes: true
  },
  swamp: {
    label: "DND5E.Habitat.Categories.Swamp"
  },
  underdark: {
    label: "DND5E.Habitat.Categories.Underdark"
  },
  underwater: {
    label: "DND5E.Habitat.Categories.Underwater"
  },
  urban: {
    label: "DND5E.Habitat.Categories.Urban"
  }
};
preLocalize("habitats", { key: "label" });

/* -------------------------------------------- */

/**
 * @typedef TreasureConfiguration5e
 * @property {string} label  The human-readable treasure category name.
 */

/**
 * NPC Treasure
 * @type {Record<string, TreasureConfiguration5e>}
 */
DND5E.treasure = {
  any: {
    label: "DND5E.Treasure.Categories.Any"
  },
  arcana: {
    label: "DND5E.Treasure.Categories.Arcana"
  },
  armaments: {
    label: "DND5E.Treasure.Categories.Armaments"
  },
  implements: {
    label: "DND5E.Treasure.Categories.Implements"
  },
  individual: {
    label: "DND5E.Treasure.Categories.Individual"
  },
  relics: {
    label: "DND5E.Treasure.Categories.Relics"
  }
};
preLocalize("treasure", { key: "label" });

/* -------------------------------------------- */

/**
 * Maximum allowed character level.
 * @type {number}
 */
DND5E.maxLevel = 20;

/**
 * Maximum ability score value allowed by default.
 * @type {number}
 */
DND5E.maxAbilityScore = 20;

/**
 * XP required to achieve each character level.
 * @type {number[]}
 */
DND5E.CHARACTER_EXP_LEVELS = [
  0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000,
  120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000
];

/**
 * XP granted for each challenge rating.
 * @type {number[]}
 */
DND5E.CR_EXP_LEVELS = [
  10, 200, 450, 700, 1100, 1800, 2300, 2900, 3900, 5000, 5900, 7200, 8400, 10000, 11500, 13000, 15000, 18000,
  20000, 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000, 105000, 120000, 135000, 155000
];

/**
 * Intervals above the maximum XP that result in an epic boon.
 * @type {number}
 */
DND5E.epicBoonInterval = 30000;

/* -------------------------------------------- */

/**
 * Trait configuration information.
 *
 * @typedef {object} TraitConfiguration
 * @property {object} labels
 * @property {string} labels.title         Localization key for the trait name.
 * @property {string} labels.localization  Prefix for a localization key that can be used to generate various
 *                                         plural variants of the trait type.
 * @property {string} [labels.all]         Localization to use for the "all" option for this trait. If not provided,
 *                                         then no all option will be available.
 * @property {string} icon                 Path to the icon used to represent this trait.
 * @property {string} [actorKeyPath]       If the trait doesn't directly map to an entry as `traits.[key]`, where is
 *                                         this trait's data stored on the actor?
 * @property {string} [configKey]          If the list of trait options doesn't match the name of the trait, where can
 *                                         the options be found within `CONFIG.DND5E`?
 * @property {boolean|number} [dataType]   Type of data represented.
 * @property {string} [labelKeyPath]       If config is an enum of objects, where can the label be found?
 * @property {object} [subtypes]           Configuration for traits that take some sort of base item.
 * @property {string} [subtypes.keyPath]   Path to subtype value on base items, should match a category key.
 *                                         Deprecated in favor of the standardized `system.type.value`.
 * @property {string[]} [subtypes.ids]     Key for base item ID objects within `CONFIG.DND5E`.
 * @property {object} [children]           Mapping of category key to an object defining its children.
 * @property {boolean} [sortCategories]    Whether top-level categories should be sorted.
 * @property {boolean} [expertise]         Can an actor receive expertise in this trait?
 * @property {boolean} [mastery]           Can an actor receive mastery in this trait?
 */

/**
 * Configurable traits on actors.
 * @enum {TraitConfiguration}
 */
DND5E.traits = {
  saves: {
    labels: {
      title: "DND5E.ClassSaves",
      localization: "DND5E.TraitSavesPlural"
    },
    icon: "icons/magic/life/ankh-gold-blue.webp",
    actorKeyPath: "system.abilities",
    configKey: "abilities",
    labelKeyPath: "label"
  },
  skills: {
    labels: {
      title: "DND5E.Skills",
      localization: "DND5E.TraitSkillsPlural"
    },
    icon: "icons/tools/instruments/harp-yellow-teal.webp",
    actorKeyPath: "system.skills",
    labelKeyPath: "label",
    expertise: true
  },
  languages: {
    labels: {
      title: "DND5E.Languages",
      localization: "DND5E.TraitLanguagesPlural",
      all: "DND5E.Language.All"
    },
    icon: "icons/skills/social/diplomacy-peace-alliance.webp"
  },
  armor: {
    labels: {
      title: "DND5E.TraitArmorProf",
      localization: "DND5E.TraitArmorPlural"
    },
    icon: "icons/equipment/chest/breastplate-helmet-metal.webp",
    actorKeyPath: "system.traits.armorProf",
    configKey: "armorProficiencies",
    subtypes: { keyPath: "armor.type", ids: ["armorIds", "shieldIds"] }
  },
  weapon: {
    labels: {
      title: "DND5E.TraitWeaponProf",
      localization: "DND5E.TraitWeaponPlural"
    },
    icon: "icons/skills/melee/weapons-crossed-swords-purple.webp",
    actorKeyPath: "system.traits.weaponProf",
    configKey: "weaponProficiencies",
    subtypes: { keyPath: "weaponType", ids: ["weaponIds"] },
    mastery: true
  },
  tool: {
    labels: {
      title: "DND5E.TraitToolProf",
      localization: "DND5E.TraitToolPlural"
    },
    icon: "icons/skills/trades/smithing-anvil-silver-red.webp",
    actorKeyPath: "system.tools",
    configKey: "toolProficiencies",
    subtypes: { keyPath: "toolType", ids: ["toolIds"] },
    children: { vehicle: "vehicleTypes" },
    sortCategories: true,
    expertise: true
  },
  di: {
    labels: {
      title: "DND5E.DamImm",
      localization: "DND5E.TraitDIPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-damage-immunities.svg",
    configKey: "damageTypes"
  },
  dr: {
    labels: {
      title: "DND5E.DamRes",
      localization: "DND5E.TraitDRPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-damage-resistances.svg",
    configKey: "damageTypes"
  },
  dv: {
    labels: {
      title: "DND5E.DamVuln",
      localization: "DND5E.TraitDVPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-damage-vulnerabilities.svg",
    configKey: "damageTypes"
  },
  dm: {
    labels: {
      title: "DND5E.DamMod",
      localization: "DND5E.TraitDMPlural"
    },
    configKey: "damageTypes",
    dataType: Number
  },
  ci: {
    labels: {
      title: "DND5E.ConImm",
      localization: "DND5E.TraitCIPlural"
    },
    icon: "systems/dnd5e/icons/svg/trait-condition-immunities.svg",
    configKey: "conditionTypes"
  }
};
preLocalize("traits", { keys: ["labels.title", "labels.all"] });

/* -------------------------------------------- */

/**
 * Modes used within a trait advancement.
 * @enum {object}
 */
DND5E.traitModes = {
  default: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Default.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Default.Hint"
  },
  expertise: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Expertise.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Expertise.Hint"
  },
  forcedExpertise: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Force.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Force.Hint"
  },
  upgrade: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Upgrade.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Upgrade.Hint"
  },
  mastery: {
    label: "DND5E.ADVANCEMENT.Trait.Mode.Mastery.Label",
    hint: "DND5E.ADVANCEMENT.Trait.Mode.Mastery.Hint"
  }
};
preLocalize("traitModes", { keys: ["label", "hint"] });

/* -------------------------------------------- */

/**
 * @typedef {object} CharacterFlagConfig
 * @property {string} name
 * @property {string} hint
 * @property {string} section
 * @property {typeof boolean|string|number} type
 * @property {string} placeholder
 * @property {string[]} [abilities]
 * @property {Object<string, string>} [choices]
 * @property {string[]} [skills]
 */

/**
 * Special character flags.
 * @enum {CharacterFlagConfig}
 */
DND5E.characterFlags = {
  diamondSoul: {
    name: "DND5E.FlagsDiamondSoul",
    hint: "DND5E.FlagsDiamondSoulHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  enhancedDualWielding: {
    name: "DND5E.FLAGS.EnhancedDualWielding.Name",
    hint: "DND5E.FLAGS.EnhancedDualWielding.Hint",
    section: "DND5E.Feats",
    type: Boolean
  },
  elvenAccuracy: {
    name: "DND5E.FlagsElvenAccuracy",
    hint: "DND5E.FlagsElvenAccuracyHint",
    section: "DND5E.RacialTraits",
    abilities: ["dex", "int", "wis", "cha"],
    type: Boolean
  },
  halflingLucky: {
    name: "DND5E.FlagsHalflingLucky",
    hint: "DND5E.FlagsHalflingLuckyHint",
    section: "DND5E.RacialTraits",
    type: Boolean
  },
  initiativeAdv: {
    name: "DND5E.FlagsInitiativeAdv",
    hint: "DND5E.FlagsInitiativeAdvHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  initiativeAlert: {
    name: "DND5E.FlagsAlert",
    hint: "DND5E.FlagsAlertHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  jackOfAllTrades: {
    name: "DND5E.FlagsJOAT",
    hint: "DND5E.FlagsJOATHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  observantFeat: {
    name: "DND5E.FlagsObservant",
    hint: "DND5E.FlagsObservantHint",
    skills: ["prc", "inv"],
    section: "DND5E.Feats",
    type: Boolean
  },
  tavernBrawlerFeat: {
    name: "DND5E.FlagsTavernBrawler",
    hint: "DND5E.FlagsTavernBrawlerHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  powerfulBuild: {
    name: "DND5E.FlagsPowerfulBuild",
    hint: "DND5E.FlagsPowerfulBuildHint",
    section: "DND5E.RacialTraits",
    type: Boolean
  },
  reliableTalent: {
    name: "DND5E.FlagsReliableTalent",
    hint: "DND5E.FlagsReliableTalentHint",
    section: "DND5E.Feats",
    type: Boolean
  },
  remarkableAthlete: {
    name: "DND5E.FlagsRemarkableAthlete",
    hint: "DND5E.FlagsRemarkableAthleteHint",
    abilities: ["str", "dex", "con"],
    section: "DND5E.Feats",
    type: Boolean
  },
  weaponCriticalThreshold: {
    name: "DND5E.FlagsWeaponCritThreshold",
    hint: "DND5E.FlagsWeaponCritThresholdHint",
    section: "DND5E.Feats",
    type: Number,
    placeholder: 20
  },
  spellCriticalThreshold: {
    name: "DND5E.FlagsSpellCritThreshold",
    hint: "DND5E.FlagsSpellCritThresholdHint",
    section: "DND5E.Feats",
    type: Number,
    placeholder: 20
  },
  meleeCriticalDamageDice: {
    name: "DND5E.FlagsMeleeCriticalDice",
    hint: "DND5E.FlagsMeleeCriticalDiceHint",
    section: "DND5E.Feats",
    type: Number,
    placeholder: 0
  }
};
preLocalize("characterFlags", { keys: ["name", "hint", "section"] });

/**
 * Flags allowed on actors. Any flags not in the list may be deleted during a migration.
 * @type {string[]}
 */
DND5E.allowedActorFlags = ["isPolymorphed", "originalActor"].concat(Object.keys(DND5E.characterFlags));

/* -------------------------------------------- */

/**
 * Different types of actor structures that groups can represent.
 * @enum {object}
 */
DND5E.groupTypes = {
  party: "DND5E.Group.TypeParty",
  encounter: "DND5E.Group.TypeEncounter"
};
preLocalize("groupTypes");

/* -------------------------------------------- */

/**
 * Configuration information for activity types.
 *
 * @typedef {object} ActivityTypeConfiguration
 * @property {typeof Activity} documentClass  The activity's document class.
 * @property {boolean} [configurable=true]    Whether the activity is editable via the UI.
 * @property {boolean} [hidden]               Should this activity type be hidden in the selection dialog?
 */
DND5E.activityTypes = {
  attack: {
    documentClass: AttackActivity
  },
  cast: {
    documentClass: CastActivity
  },
  check: {
    documentClass: CheckActivity
  },
  damage: {
    documentClass: DamageActivity
  },
  enchant: {
    documentClass: EnchantActivity
  },
  forward: {
    documentClass: ForwardActivity
  },
  heal: {
    documentClass: HealActivity
  },
  order: {
    documentClass: OrderActivity,
    configurable: false
  },
  save: {
    documentClass: SaveActivity
  },
  summon: {
    documentClass: SummonActivity
  },
  utility: {
    documentClass: UtilityActivity
  }
};

/* -------------------------------------------- */

/**
 * Configuration information for advancement types.
 *
 * @typedef {object} AdvancementTypeConfiguration
 * @property {typeof Advancement} documentClass  The advancement's document class.
 * @property {Set<string>} validItemTypes        What item types this advancement can be used with.
 * @property {boolean} [hidden]                  Should this advancement type be hidden in the selection dialog?
 */

const _ALL_ITEM_TYPES = ["background", "class", "race", "subclass"];

/**
 * Advancement types that can be added to items.
 * @enum {AdvancementTypeConfiguration}
 */
DND5E.advancementTypes = {
  AbilityScoreImprovement: {
    documentClass: AbilityScoreImprovementAdvancement,
    validItemTypes: new Set(["background", "class", "race", "feat"])
  },
  HitPoints: {
    documentClass: HitPointsAdvancement,
    validItemTypes: new Set(["class"])
  },
  ItemChoice: {
    documentClass: ItemChoiceAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  },
  ItemGrant: {
    documentClass: ItemGrantAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  },
  ScaleValue: {
    documentClass: ScaleValueAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  },
  Size: {
    documentClass: SizeAdvancement,
    validItemTypes: new Set(["race"])
  },
  Subclass: {
    documentClass: SubclassAdvancement,
    validItemTypes: new Set(["class"])
  },
  Trait: {
    documentClass: TraitAdvancement,
    validItemTypes: new Set(_ALL_ITEM_TYPES)
  }
};

/* -------------------------------------------- */

/**
 * Default artwork configuration for each Document type and sub-type.
 * @type {Record<string, Record<string, string>>}
 */
DND5E.defaultArtwork = {
  Item: {
    background: "systems/dnd5e/icons/svg/items/background.svg",
    class: "systems/dnd5e/icons/svg/items/class.svg",
    consumable: "systems/dnd5e/icons/svg/items/consumable.svg",
    container: "systems/dnd5e/icons/svg/items/container.svg",
    equipment: "systems/dnd5e/icons/svg/items/equipment.svg",
    facility: "systems/dnd5e/icons/svg/items/facility.svg",
    feat: "systems/dnd5e/icons/svg/items/feature.svg",
    loot: "systems/dnd5e/icons/svg/items/loot.svg",
    race: "systems/dnd5e/icons/svg/items/race.svg",
    spell: "systems/dnd5e/icons/svg/items/spell.svg",
    subclass: "systems/dnd5e/icons/svg/items/subclass.svg",
    tool: "systems/dnd5e/icons/svg/items/tool.svg",
    weapon: "systems/dnd5e/icons/svg/items/weapon.svg"
  }
};

/* -------------------------------------------- */
/*  Rules                                       */
/* -------------------------------------------- */

/**
 * Configuration information for rule types.
 *
 * @typedef {object} RuleTypeConfiguration
 * @property {string} label         Localized label for the rule type.
 * @property {string} [references]  Key path for a configuration object that contains reference data.
 */

/**
 * Types of rules that can be used in rule pages and the &Reference enricher.
 * @enum {RuleTypeConfiguration}
 */
DND5E.ruleTypes = {
  rule: {
    label: "DND5E.Rule.Type.Rule",
    references: "rules"
  },
  ability: {
    label: "DND5E.Ability",
    references: "enrichmentLookup.abilities"
  },
  areaOfEffect: {
    label: "DND5E.AreaOfEffect.Label",
    references: "areaTargetTypes"
  },
  condition: {
    label: "DND5E.Rule.Type.Condition",
    references: "conditionTypes"
  },
  creatureType: {
    label: "DND5E.CreatureType",
    references: "creatureTypes"
  },
  damage: {
    label: "DND5E.DamageType",
    references: "damageTypes"
  },
  skill: {
    label: "DND5E.Skill",
    references: "enrichmentLookup.skills"
  },
  spellComponent: {
    label: "DND5E.SpellComponent",
    references: "itemProperties"
  },
  spellSchool: {
    label: "DND5E.SpellSchool",
    references: "enrichmentLookup.spellSchools"
  },
  spellTag: {
    label: "DND5E.SpellTag",
    references: "itemProperties"
  },
  weaponMastery: {
    label: "DND5E.WEAPON.Mastery.Label",
    references: "weaponMasteries"
  }
};
preLocalize("ruleTypes", { key: "label" });

/* -------------------------------------------- */

/**
 * List of rules that can be referenced from enrichers.
 * @enum {string}
 */
DND5E.rules = {
  inspiration: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.nkEPI89CiQnOaLYh",
  carryingcapacity: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1PnjDBKbQJIVyc2t",
  push: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Hni8DjqLzoqsVjb6",
  lift: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Hni8DjqLzoqsVjb6",
  drag: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Hni8DjqLzoqsVjb6",
  encumbrance: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.JwqYf9qb6gJAWZKs",
  hiding: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.plHuoNdS0j3umPNS",
  passiveperception: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.988C2hQNyvqkdbND",
  time: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eihqNjwpZ3HM4IqY",
  speed: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HhqeIiSj8sE1v1qZ",
  travelpace: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.eFAISahBloR2X8MX",
  forcedmarch: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.uQWQpRKQ1kWhuvjZ",
  difficultterrainpace: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hFW5BR2yHHwwgurD",
  climbing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KxUXbMrUCIAhv4AF",
  swimming: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KxUXbMrUCIAhv4AF",
  longjump: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1U0myNrOvIVBUdJV",
  highjump: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.raPwIkqKSv60ELmy",
  falling: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kREHL5pgNUOhay9f",
  suffocating: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.BIlnr0xYhqt4TGsi",
  vision: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.O6hamUbI9kVASN8b",
  light: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.O6hamUbI9kVASN8b",
  lightlyobscured: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MAxtfJyvJV7EpzWN",
  heavilyobscured: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.wPFjfRruboxhtL4b",
  brightlight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RnMokVPyKGbbL8vi",
  dimlight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.n1Ocpbyhr6HhgbCG",
  darkness: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4dfREIDjG5N4fvxd",
  blindsight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.sacjsfm9ZXnw4Tqc",
  darkvision: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ldmA1PbnEGVkmE11",
  tremorsense: "Compendium.dnd5e.rules.JournalEntry.eVtpEGXjA2tamEIJ.JournalEntryPage.8AIlZ95v54mL531X",
  truesight: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kNa8rJFbtaTM3Rmk",
  food: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jayo7XVgGnRCpTW0",
  water: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iIEI87J7lr2sqtb5",
  resting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.dpHJXYLigIdEseIb",
  shortrest: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.1s2swI3UsjUUgbt2",
  longrest: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6cLtjbHn4KV2R7G9",
  surprise: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.YmOt8HderKveA19K",
  initiative: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RcwElV4GAcVXKWxo",
  bonusaction: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.2fu2CXsDg8gQmGGw",
  reaction: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.2VqLyxMyMxgXe2wC",
  difficultterrain: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6tqz947qO8vPyxvD",
  beingprone: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.bV8akkBdVUUG21CO",
  droppingprone: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hwTLpAtSS5OqQsI1",
  standingup: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hwTLpAtSS5OqQsI1",
  crawling: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.VWG9qe8PUNtS28Pw",
  movingaroundothercreatures: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9ZWCknaXCOdhyOrX",
  flying: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.0B1fxfmw0a48tPsc",
  size: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HWHRQVBVG7K0RVVW",
  space: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.WIA5bs3P45PmO3OS",
  squeezing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.wKtOwagDAiNfVoPS",
  attack: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.u4GQCzoBig20yRLj",
  castaspell: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.GLwN36E4WXn3Cp4Z",
  dash: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Jqn0MEvq6fduYNo6",
  disengage: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ZOPRfI48NyjoloEF",
  dodge: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.V1BkwK2HQrtEfa4d",
  help: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KnrD3u2AnQfmtOWj",
  hide: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.BXlHhE4ZoiFwiXLK",
  ready: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8xJzZVelP2AmQGfU",
  search: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5cn1ZTLgQq95vfZx",
  useanobject: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ljqhJx8Qxu2ivo69",
  attackrolls: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5wkqEqhbBD5kDeE7",
  unseenattackers: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5ZJNwEPlsGurecg5",
  unseentargets: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.5ZJNwEPlsGurecg5",
  rangedattacks: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.S9aclVOCbusLE3kC",
  range: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HjKXuB8ndjcqOds7",
  rangedattacksinclosecombat: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.qEZvxW0NM7ixSQP5",
  meleeattacks: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.GTk6emvzNxl8Oosl",
  reach: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hgZ5ZN4B3y7tmFlt",
  unarmedstrike: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.xJjJ4lhymAYXAOvO",
  opportunityattacks: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zeU0NyCyP10lkLg3",
  twoweaponfighting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.FQTS08uH74A6psL2",
  grappling: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Sl4bniSPSbyrakM2",
  escapingagrapple: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.2TZKy9YbMN3ZY3h8",
  movingagrappledcreature: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.x5bUdhAD7u5Bt2rg",
  shoving: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hrdqMF8hRXJdNzJx",
  cover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.W7f7PcRubNUMIq2S",
  halfcover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hv0J61IAfofuhy3Q",
  threequarterscover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zAMStUjUrPV10dFm",
  totalcover: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.BKUAxXuPEzxiEOeL",
  hitpoints: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.PFbzoMBviI2DD9QP",
  damagerolls: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.hd26AqKrCqtcQBWy",
  criticalhits: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gFL1VhSEljL1zvje",
  damagetypes: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jVOgf7DNEhkzYNIe",
  damageresistance: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.v0WE18nT5SJO8Ft7",
  damagevulnerability: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.v0WE18nT5SJO8Ft7",
  healing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ICketFqbFslqKiX9",
  instantdeath: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8BG05mA0mEzwmrHU",
  deathsavingthrows: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.JL8LePEJQYFdNuLL",
  deathsaves: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.JL8LePEJQYFdNuLL",
  stabilizing: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.r1CgZXLcqFop6Dlx",
  knockingacreatureout: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.uEwjgKGuCRTNADYv",
  temporaryhitpoints: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AW6HpJZHqxfESXaq",
  temphp: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.AW6HpJZHqxfESXaq",
  mounting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MFpyvUIdcBpC9kIE",
  dismounting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.MFpyvUIdcBpC9kIE",
  controllingamount: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.khmR2xFk1NxoQUgZ",
  underwatercombat: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6zVOeLyq4iMnrQT4",
  spelllevel: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.A6k5fS0kFqPXTW3v",
  knownspells: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oezg742GlxmEwT85",
  preparedspells: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oezg742GlxmEwT85",
  spellslots: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Su6wbb0O9UN4ZDIH",
  castingatahigherlevel: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4H9SLM95OCLfFizz",
  upcasting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.4H9SLM95OCLfFizz",
  castinginarmor: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.z4A8vHSK2pb8YA9X",
  cantrips: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.jZD5mCTnMPJ9jW67",
  rituals: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.FjWqT5iyJ89kohdA",
  castingtime: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.zRVW8Tvyk6BECjZD",
  bonusactioncasting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RP1WL9FXI3aknlxZ",
  reactioncasting: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.t62lCfinwU9H7Lji",
  longercastingtimes: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gOAIRFCyPUx42axn",
  spellrange: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.RBYPyE5z5hAZSbH6",
  components: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.xeHthAF9lxfn2tII",
  verbal: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6UXTNWMCQ0nSlwwx",
  spellduration: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9mp0SRsptjvJcq1e",
  instantaneous: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kdlgZOpRMB6bGCod",
  concentrating: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.ow58p27ctAnr4VPH",
  spelltargets: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.G80AIQr04sxdVpw4",
  areaofeffect: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.wvtCeGHgnUmh0cuj",
  pointoforigin: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8HxbRceQQUAhyWRt",
  spellsavingthrows: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.8DajfNll90eeKcmB",
  spellattackrolls: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.qAFzmGZKhVvAEUF3",
  combiningmagicaleffects: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TMIN963hG773yZzO",
  schoolsofmagic: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.TeF6CKMDRpYpsLd4",
  detectingtraps: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DZ7AhdQ94xggG4bj",
  disablingtraps: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DZ7AhdQ94xggG4bj",
  curingmadness: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.6Icem7G3CICdNOkM",
  damagethreshold: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9LJZhqvCburpags3",
  poisontypes: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.I6OMMWUaYCWR9xip",
  contactpoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.kXnCEqqGUWRZeZDj",
  ingestedpoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.Y0vsJYSWeQcFpJ27",
  inhaledpoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.KUyN4eK1xTBzXsjP",
  injurypoison: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.LUL48OUq6SJeMGc7",
  attunement: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.UQ65OwIyGK65eiOK",
  wearingitems: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iPB8mGKuQx3X0Z2J",
  wieldingitems: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.iPB8mGKuQx3X0Z2J",
  multipleitemsofthesamekind: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.rLJdvz4Mde8GkEYQ",
  paireditems: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.rd9pCH8yFraSGN34",
  commandword: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.HiXixxLYesv6Ff3t",
  consumables: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.UEPAcZFzQ5x196zE",
  itemspells: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.DABoaeeF6w31UCsj",
  charges: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.NLRXcgrpRCfsA5mO",
  spellscroll: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.gi8IKhtOlBVhMJrN",
  creaturetags: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.9jV1fFF163dr68vd",
  telepathy: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.geTidcFIYWuUvD2L",
  legendaryactions: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.C1awOyZh78pq1xmY",
  lairactions: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.07PtjpMxiRIhkBEp",
  regionaleffects: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.uj8W27NKFyzygPUd",
  disease: "Compendium.dnd5e.rules.JournalEntry.NizgRXLNUqtdlC1s.JournalEntryPage.oNQWvyRZkTOJ8PBq"
};

/* -------------------------------------------- */
/*  Token Rings Framework                       */
/* -------------------------------------------- */

/**
 * Token Rings configuration data
 *
 * @typedef {object} TokenRingsConfiguration
 * @property {Record<string, string>} effects        Localized names of the configurable ring effects.
 * @property {string} spriteSheet                    The sprite sheet json source.
 * @property {typeof BaseSamplerShader} shaderClass  The shader class definition associated with the token ring.
 */

/**
 * @type {TokenRingsConfiguration}
 */
DND5E.tokenRings = {
  effects: {
    RING_PULSE: "DND5E.TokenRings.Effects.RingPulse",
    RING_GRADIENT: "DND5E.TokenRings.Effects.RingGradient",
    BKG_WAVE: "DND5E.TokenRings.Effects.BackgroundWave"
  },
  spriteSheet: "systems/dnd5e/tokens/composite/token-rings.json",
  shaderClass: null
};
preLocalize("tokenRings.effects");

/* -------------------------------------------- */
/*  Sources                                     */
/* -------------------------------------------- */

/**
 * List of books available as sources.
 * @enum {string}
 */
DND5E.sourceBooks = {};
preLocalize("sourceBooks", { sort: true });

/* -------------------------------------------- */
/*  Themes                                      */
/* -------------------------------------------- */

/**
 * Themes that can be set for the system or on sheets.
 * @enum {string}
 */
DND5E.themes = {
  light: "SHEETS.DND5E.THEME.Light",
  dark: "SHEETS.DND5E.THEME.Dark"
};
preLocalize("themes");

/* -------------------------------------------- */
/*  Enrichment                                  */
/* -------------------------------------------- */

let _enrichmentLookup;
Object.defineProperty(DND5E, "enrichmentLookup", {
  get() {
    const slugify = value => value?.slugify().replaceAll("-", "");
    if ( !_enrichmentLookup ) {
      _enrichmentLookup = {
        abilities: foundry.utils.deepClone(DND5E.abilities),
        skills: foundry.utils.deepClone(DND5E.skills),
        spellSchools: foundry.utils.deepClone(DND5E.spellSchools),
        tools: foundry.utils.deepClone(DND5E.toolIds)
      };
      const addFullKeys = key => Object.entries(DND5E[key]).forEach(([k, v]) =>
        _enrichmentLookup[key][slugify(v.fullKey)] = { ...v, key: k }
      );
      addFullKeys("abilities");
      addFullKeys("skills");
      addFullKeys("spellSchools");
    }
    return _enrichmentLookup;
  },
  enumerable: true
});

/* -------------------------------------------- */

/**
 * Patch an existing config enum to allow conversion from string values to object values without
 * breaking existing modules that are expecting strings.
 * @param {string} key          Key within DND5E that has been replaced with an enum of objects.
 * @param {string} fallbackKey  Key within the new config object from which to get the fallback value.
 * @param {object} [options]    Additional options passed through to logCompatibilityWarning.
 */
function patchConfig(key, fallbackKey, options) {
  /** @override */
  function toString() {
    const message = `The value of CONFIG.DND5E.${key} has been changed to an object.`
      +` The former value can be acccessed from .${fallbackKey}.`;
    foundry.utils.logCompatibilityWarning(message, options);
    return this[fallbackKey];
  }

  Object.values(DND5E[key]).forEach(o => {
    if ( foundry.utils.getType(o) !== "Object" ) return;
    Object.defineProperty(o, "toString", {value: toString});
  });
}

const { BooleanField: BooleanField$f, NumberField: NumberField$m, StringField: StringField$w } = foundry.data.fields;

/**
 * Base application for configuring system settings.
 */
class BaseSettingsConfig extends Application5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    tag: "form",
    classes: ["standard-form"],
    position: {
      width: 500
    },
    form: {
      closeOnSubmit: true,
      handler: BaseSettingsConfig.#onCommitChanges
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.fields = [];
    context.buttons = [{ type: "submit", icon: "fas fa-save", label: "Save Changes" }];
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Create the field data for a specific setting.
   * @param {string} name  Setting key within the dnd5e namespace.
   * @returns {object}
   */
  createSettingField(name) {
    const setting = game.settings.settings.get(`dnd5e.${name}`);
    if ( !setting ) throw new Error(`Setting \`dnd5e.${name}\` not registered.`);
    const Field = { [Boolean]: BooleanField$f, [Number]: NumberField$m, [String]: StringField$w }[setting.type];
    if ( !Field ) throw new Error("Automatic field generation only available for Boolean, Number, or String types");
    const data = {
      field: new Field({ label: game.i18n.localize(setting.name), hint: game.i18n.localize(setting.hint) }),
      name,
      value: game.settings.get("dnd5e", name)
    };
    if ( setting.type === Boolean ) data.input = createCheckboxInput;
    if ( setting.choices ) data.options = Object.entries(setting.choices)
      .map(([value, label]) => ({ value, label: game.i18n.localize(label) }));
    return data;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Commit settings changes.
   * @this {BaseSettingsConfig}
   * @param {SubmitEvent} event          The submission event.
   * @param {HTMLFormElement} form       The submitted form element.
   * @param {FormDataExtended} formData  The submitted form data.
   * @returns {Promise}
   */
  static async #onCommitChanges(event, form, formData) {
    for ( const [key, value] of Object.entries(foundry.utils.expandObject(formData.object)) ) {
      await game.settings.set("dnd5e", key, value);
    }
  }
}

/**
 * An application for configuring bastion settings.
 */
class BastionSettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "DND5E.Bastion.Configuration.Label"
    }
  };

  /** @override */
  static PARTS = {
    ...super.PARTS,
    config: {
      template: "systems/dnd5e/templates/settings/bastion-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.fields = BastionSetting.schema.fields;
    context.source = game.settings.get("dnd5e", "bastionConfiguration");
    return context;
  }
}

/* -------------------------------------------- */

const { BooleanField: BooleanField$e, NumberField: NumberField$l } = foundry.data.fields;

/**
 * A data model that represents the Bastion configuration options.
 */
class BastionSetting extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      button: new BooleanField$e({
        required: true, label: "DND5E.Bastion.Button.Label", hint: "DND5E.Bastion.Button.Hint"
      }),
      duration: new NumberField$l({
        required: true, positive: true, integer: true, initial: 7, label: "DND5E.Bastion.Duration.Label"
      }),
      enabled: new BooleanField$e({
        required: true, label: "DND5E.Bastion.Enabled.Label", hint: "DND5E.Bastion.Enabled.Hint"
      })
    };
  }
}

/**
 * An application for configuring combat settings.
 */
class CombatSettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "SETTINGS.DND5E.COMBAT.Label"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    initiative: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    criticals: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    npcs: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "initiative":
        context.fields = [
          this.createSettingField("initiativeDexTiebreaker"),
          this.createSettingField("initiativeScore")
        ];
        context.legend = game.i18n.localize("DND5E.Initiative");
        break;
      case "criticals":
        context.fields = [
          this.createSettingField("criticalDamageModifiers"),
          this.createSettingField("criticalDamageMaxDice")
        ];
        context.legend = game.i18n.localize("SETTINGS.DND5E.CRITICAL.Name");
        break;
      case "npcs":
        context.fields = [
          this.createSettingField("autoRecharge"),
          this.createSettingField("autoRollNPCHP")
        ];
        context.legend = game.i18n.localize("SETTINGS.DND5E.NPCS.Name");
        break;
    }
    return context;
  }
}

/**
 * @typedef {object} ModuleArtInfo
 * @property {string} actor         The path to the actor's portrait image.
 * @property {string|object} token  The path to the token image, or a richer object specifying additional token
 *                                  adjustments.
 */

/**
 * A class responsible for managing module-provided art in compendia.
 */
class ModuleArt {
  constructor() {
    /**
     * The stored map of actor UUIDs to their art information.
     * @type {Map<string, ModuleArtInfo>}
     */
    Object.defineProperty(this, "map", {value: new Map(), writable: false});
  }

  /* -------------------------------------------- */

  /**
   * Set to true to temporarily prevent actors from loading module art.
   * @type {boolean}
   */
  suppressArt = false;

  /* -------------------------------------------- */

  /**
   * Register any art mapping information included in active modules.
   * @returns {Promise<void>}
   */
  async registerModuleArt() {
    this.map.clear();
    // Load art modules in reverse order so that higher-priority modules overwrite lower-priority ones.
    for ( const { id, mapping, credit } of this.constructor.getArtModules().reverse() ) {
      try {
        const json = await foundry.utils.fetchJsonWithTimeout(mapping);
        await this.#parseArtMapping(id, json, credit);
      } catch(e) {
        console.error(e);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Parse a provided module art mapping and store it for reference later.
   * @param {string} moduleId  The module ID.
   * @param {object} mapping   A mapping containing pack names, a list of actor IDs, and paths to the art provided by
   *                           the module for them.
   * @param {string} [credit]  An optional credit line to attach to the Actor's biography.
   * @returns {Promise<void>}
   */
  async #parseArtMapping(moduleId, mapping, credit) {
    let settings = game.settings.get("dnd5e", "moduleArtConfiguration")?.[moduleId];
    settings ??= {portraits: true, tokens: true};
    for ( const [packName, actors] of Object.entries(mapping) ) {
      const pack = game.packs.get(packName);
      if ( !pack ) continue;
      for ( let [actorId, info] of Object.entries(actors) ) {
        const entry = pack.index.get(actorId);
        if ( !entry || !(settings.portraits || settings.tokens) ) continue;
        if ( settings.portraits ) entry.img = info.actor;
        else delete info.actor;
        if ( !settings.tokens ) delete info.token;
        if ( credit ) info.credit = credit;
        const uuid = `Compendium.${packName}.${actorId}`;
        info = foundry.utils.mergeObject(this.map.get(uuid) ?? {}, info, {inplace: false});
        this.map.set(`Compendium.${packName}.${actorId}`, info);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply module portrait art to a freshly generated compendium index.
   * @param {Collection} index  Index generated by `CompendiumCollection#getIndex`.
   * @returns {Collection}
   */
  apply(index) {
    for ( const entry of index ) {
      entry.img ??= this.map.get(entry.uuid)?.actor ?? this.map.get(entry.uuid.replace("Actor.", ""))?.actor;
    }
    return index;
  }

  /* -------------------------------------------- */

  /**
   * If a module provides art, return the path to is JSON mapping.
   * @param {Module} module  The module.
   * @returns {string|null}
   */
  static getModuleArtPath(module) {
    const flags = module.flags?.[module.id];
    const artPath = flags?.["dnd5e-art"];
    if ( !artPath || !module.active ) return null;
    return artPath;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {object} ModuleArtDescriptor
   * @property {string} id        The module ID.
   * @property {string} label     The module title.
   * @property {string} mapping   The path to the art mapping file.
   * @property {string} [credit]  An optional credit line to attack to the Actor's biography.
   * @property {number} priority  The module's user-configured priority.
   */

  /**
   * Returns all currently configured art modules in priority order.
   * @returns {ModuleArtDescriptor[]}
   */
  static getArtModules() {
    const settings = game.settings.get("dnd5e", "moduleArtConfiguration");
    const unsorted = [];
    const configs = [{
      id: game.system.id,
      label: game.system.title,
      mapping: "systems/dnd5e/json/fa-token-mapping.json",
      priority: settings.dnd5e?.priority ?? CONST.SORT_INTEGER_DENSITY,
      credit: `
        <em>
          Token artwork by
          <a href="https://www.forgotten-adventures.net/" target="_blank" rel="noopener">Forgotten Adventures</a>.
        </em>
      `
    }];

    for ( const module of game.modules ) {
      const flags = module.flags?.[module.id];
      const mapping = this.getModuleArtPath(module);
      if ( !mapping ) continue;
      const config = { id: module.id, label: module.title, credit: flags?.["dnd5e-art-credit"], mapping };
      configs.push(config);
      const priority = settings[module.id]?.priority;
      if ( priority === undefined ) unsorted.push(config);
      else config.priority = priority;
    }

    const maxPriority = Math.max(...configs.map(({ priority }) => priority ?? -Infinity));
    unsorted.forEach((config, i) => config.priority = maxPriority + ((i + 1) * CONST.SORT_INTEGER_DENSITY));
    configs.sort((a, b) => a.priority - b.priority);
    return configs;
  }
}

/**
 * A class responsible for allowing GMs to configure art provided by installed modules.
 */
class ModuleArtSettingsConfig extends FormApplication {
  /** @inheritDoc */
  constructor(object={}, options={}) {
    object = foundry.utils.mergeObject(game.settings.get("dnd5e", "moduleArtConfiguration"), object, {inplace: false});
    super(object, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      title: game.i18n.localize("DND5E.ModuleArtConfigL"),
      id: "module-art-config",
      template: "systems/dnd5e/templates/apps/module-art-config.hbs",
      popOut: true,
      width: 600,
      height: "auto"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options={}) {
    const context = super.getData(options);
    context.config = [];
    for ( const config of ModuleArt.getArtModules() ) {
      const settings = this.object[config.id] ?? { portraits: true, tokens: true };
      context.config.push({ ...config, ...settings });
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("[data-action]").on("click", this._onAction.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Handle priority increase or decrease actions.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onAction(event) {
    const action = event.currentTarget.dataset.action;
    const item = event.currentTarget.closest("[data-id]");
    const id = item.dataset.id;
    const configs = [];
    for ( const element of this.form.elements ) {
      const [id, key] = element.name.split(".");
      if ( key === "priority" ) configs.push({ id, priority: Number(element.value) });
    }
    const idx = configs.findIndex(config => config.id === id);
    if ( idx < 0 ) return;
    if ( (action === "increase") && (idx === 0) ) return;
    if ( (action === "decrease") && (idx === configs.length - 1) ) return;
    const sortBefore = action === "increase";
    const config = configs[idx];
    const target = configs[sortBefore ? idx - 1 : idx + 1];
    configs.splice(idx, 1);
    const updates = SortingHelpers.performIntegerSort(config, {
      target, sortBefore,
      siblings: configs,
      sortKey: "priority"
    });
    updates.forEach(({ target, update }) => this.form.elements[`${target.id}.priority`].value = update.priority);
    if ( action === "increase" ) item.previousElementSibling.insertAdjacentElement("beforebegin", item);
    else item.nextElementSibling.insertAdjacentElement("afterend", item);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    await game.settings.set("dnd5e", "moduleArtConfiguration", foundry.utils.expandObject(formData));
    return SettingsConfig.reloadConfirm({world: true});
  }
}

/**
 * An application for configuring variant rules settings.
 */
class VariantRulesSettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "SETTINGS.DND5E.VARIANT.Label"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    general: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    encumbrance: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    abilities: {
      template: "systems/dnd5e/templates/settings/base-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "general":
        context.fields = [
          game.settings.get("dnd5e", "rulesVersion") === "legacy" ? this.createSettingField("allowFeats") : null,
          this.createSettingField("restVariant"),
          this.createSettingField("proficiencyModifier"),
          this.createSettingField("levelingMode")
        ].filter(_ => _);
        context.legend = game.i18n.localize("SETTINGS.DND5E.General");
        break;
      case "encumbrance":
        context.fields = [
          this.createSettingField("encumbrance"),
          this.createSettingField("currencyWeight")
        ];
        context.legend = game.i18n.localize("DND5E.Encumbrance");
        break;
      case "abilities":
        context.fields = [
          this.createSettingField("honorScore"),
          this.createSettingField("sanityScore")
        ];
        context.legend = game.i18n.localize("DND5E.Abilities");
        break;
    }
    return context;
  }
}

/**
 * An application for configuring player visibility settings.
 */
class VisibilitySettingsConfig extends BaseSettingsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    window: {
      title: "SETTINGS.DND5E.VISIBILITY.Label"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.fields = [
      this.createSettingField("challengeVisibility"),
      this.createSettingField("attackRollVisibility"),
      this.createSettingField("bloodied"),
      this.createSettingField("concealItemDescriptions")
    ];
    return context;
  }
}

/**
 * Register all of the system's keybindings.
 */
function registerSystemKeybindings() {
  game.keybindings.register("dnd5e", "skipDialogNormal", {
    name: "KEYBINDINGS.DND5E.SkipDialogNormal",
    editable: [{ key: "ShiftLeft" }, { key: "ShiftRight" }]
  });

  game.keybindings.register("dnd5e", "skipDialogAdvantage", {
    name: "KEYBINDINGS.DND5E.SkipDialogAdvantage",
    editable: [{ key: "AltLeft" }, { key: "AltRight" }]
  });

  game.keybindings.register("dnd5e", "skipDialogDisadvantage", {
    name: "KEYBINDINGS.DND5E.SkipDialogDisadvantage",
    editable: [{ key: "ControlLeft" }, { key: "ControlRight" }, { key: "OsLeft" }, { key: "OsRight" }]
  });

  game.keybindings.register("dnd5e", "dragCopy", {
    name: "KEYBINDINGS.DND5E.DragCopy",
    editable: [{ key: "ControlLeft" }, { key: "ControlRight" }, { key: "AltLeft" }, { key: "AltRight" }]
  });

  game.keybindings.register("dnd5e", "dragMove", {
    name: "KEYBINDINGS.DND5E.DragMove",
    editable: [{ key: "ShiftLeft" }, { key: "ShiftRight" }, { key: "OsLeft" }, { key: "OsRight" }]
  });
}

/* -------------------------------------------- */

/**
 * Register all of the system's settings.
 */
function registerSystemSettings() {
  // Internal System Migration Version
  game.settings.register("dnd5e", "systemMigrationVersion", {
    name: "System Migration Version",
    scope: "world",
    config: false,
    type: String,
    default: ""
  });

  // Rules version
  game.settings.register("dnd5e", "rulesVersion", {
    name: "SETTINGS.DND5E.RULESVERSION.Name",
    hint: "SETTINGS.DND5E.RULESVERSION.Hint",
    scope: "world",
    config: true,
    default: "modern",
    type: String,
    choices: {
      modern: "SETTINGS.DND5E.RULESVERSION.Modern",
      legacy: "SETTINGS.DND5E.RULESVERSION.Legacy"
    },
    requiresReload: true
  });

  // Allow rotating square templates
  game.settings.register("dnd5e", "gridAlignedSquareTemplates", {
    name: "SETTINGS.5eGridAlignedSquareTemplatesN",
    hint: "SETTINGS.5eGridAlignedSquareTemplatesL",
    scope: "world",
    config: true,
    default: true,
    type: Boolean
  });

  // Loyalty
  game.settings.register("dnd5e", "loyaltyScore", {
    name: "SETTINGS.DND5E.LOYALTY.Name",
    hint: "SETTINGS.DND5E.LOYALTY.Hint",
    scope: "world",
    config: true,
    default: false,
    type: Boolean
  });

  // Disable Advancements
  game.settings.register("dnd5e", "disableAdvancements", {
    name: "SETTINGS.5eNoAdvancementsN",
    hint: "SETTINGS.5eNoAdvancementsL",
    scope: "world",
    config: true,
    default: false,
    type: Boolean
  });

  // Disable Concentration Tracking
  game.settings.register("dnd5e", "disableConcentration", {
    name: "SETTINGS.5eNoConcentrationN",
    hint: "SETTINGS.5eNoConcentrationL",
    scope: "world",
    config: true,
    default: false,
    type: Boolean
  });

  // Collapse Item Cards (by default)
  game.settings.register("dnd5e", "autoCollapseItemCards", {
    name: "SETTINGS.5eAutoCollapseCardN",
    hint: "SETTINGS.5eAutoCollapseCardL",
    scope: "client",
    config: true,
    default: false,
    type: Boolean,
    onChange: s => {
      ui.chat.render();
    }
  });

  // Collapse Chat Card Trays
  game.settings.register("dnd5e", "autoCollapseChatTrays", {
    name: "SETTINGS.DND5E.COLLAPSETRAYS.Name",
    hint: "SETTINGS.DND5E.COLLAPSETRAYS.Hint",
    scope: "client",
    config: true,
    default: "older",
    type: String,
    choices: {
      never: "SETTINGS.DND5E.COLLAPSETRAYS.Never",
      older: "SETTINGS.DND5E.COLLAPSETRAYS.Older",
      always: "SETTINGS.DND5E.COLLAPSETRAYS.Always"
    }
  });

  // Allow Polymorphing
  game.settings.register("dnd5e", "allowPolymorphing", {
    name: "SETTINGS.5eAllowPolymorphingN",
    hint: "SETTINGS.5eAllowPolymorphingL",
    scope: "world",
    config: true,
    default: false,
    type: Boolean
  });

  // Polymorph Settings
  game.settings.register("dnd5e", "polymorphSettings", {
    scope: "client",
    default: {
      keepPhysical: false,
      keepMental: false,
      keepSaves: false,
      keepSkills: false,
      mergeSaves: false,
      mergeSkills: false,
      keepClass: false,
      keepFeats: false,
      keepSpells: false,
      keepItems: false,
      keepBio: false,
      keepVision: true,
      keepSelf: false,
      keepAE: false,
      keepOriginAE: true,
      keepOtherOriginAE: true,
      keepFeatAE: true,
      keepSpellAE: true,
      keepEquipmentAE: true,
      keepClassAE: true,
      keepBackgroundAE: true,
      transformTokens: true
    }
  });

  // Allow Summoning
  game.settings.register("dnd5e", "allowSummoning", {
    name: "SETTINGS.DND5E.ALLOWSUMMONING.Name",
    hint: "SETTINGS.DND5E.ALLOWSUMMONING.Hint",
    scope: "world",
    config: true,
    default: false,
    type: Boolean
  });

  // Metric Length Weights
  game.settings.register("dnd5e", "metricLengthUnits", {
    name: "SETTINGS.DND5E.METRIC.LengthUnits.Name",
    hint: "SETTINGS.DND5E.METRIC.LengthUnits.Hint",
    scope: "world",
    config: true,
    type: Boolean,
    default: false
  });

  // Metric Volume Weights
  game.settings.register("dnd5e", "metricVolumeUnits", {
    name: "SETTINGS.DND5E.METRIC.VolumeUnits.Name",
    hint: "SETTINGS.DND5E.METRIC.VolumeUnits.Hint",
    scope: "world",
    config: true,
    type: Boolean,
    default: false
  });

  // Metric Unit Weights
  game.settings.register("dnd5e", "metricWeightUnits", {
    name: "SETTINGS.DND5E.METRIC.WeightUnits.Name",
    hint: "SETTINGS.DND5E.METRIC.WeightUnits.Hint",
    scope: "world",
    config: true,
    type: Boolean,
    default: false
  });

  // Strict validation
  game.settings.register("dnd5e", "strictValidation", {
    scope: "world",
    config: false,
    type: Boolean,
    default: true
  });

  // Dynamic art.
  game.settings.registerMenu("dnd5e", "moduleArtConfiguration", {
    name: "DND5E.ModuleArtConfigN",
    label: "DND5E.ModuleArtConfigL",
    hint: "DND5E.ModuleArtConfigH",
    icon: "fa-solid fa-palette",
    type: ModuleArtSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "moduleArtConfiguration", {
    name: "Module Art Configuration",
    scope: "world",
    config: false,
    type: Object,
    default: {
      dnd5e: {
        portraits: true,
        tokens: true
      }
    }
  });

  // Compendium Browser source exclusion
  game.settings.registerMenu("dnd5e", "packSourceConfiguration", {
    name: "DND5E.CompendiumBrowser.Sources.Name",
    label: "DND5E.CompendiumBrowser.Sources.Label",
    hint: "DND5E.CompendiumBrowser.Sources.Hint",
    icon: "fas fa-book-open-reader",
    type: CompendiumBrowserSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "packSourceConfiguration", {
    name: "Pack Source Configuration",
    scope: "world",
    config: false,
    type: Object,
    default: {}
  });

  // Bastions
  game.settings.registerMenu("dnd5e", "bastionConfiguration", {
    name: "DND5E.Bastion.Configuration.Name",
    label: "DND5E.Bastion.Configuration.Label",
    hint: "DND5E.Bastion.Configuration.Hint",
    icon: "fas fa-chess-rook",
    type: BastionSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "bastionConfiguration", {
    name: "Bastion Configuration",
    scope: "world",
    config: false,
    type: BastionSetting,
    default: {
      button: false,
      enabled: false,
      duration: 7
    },
    onChange: () => game.dnd5e.bastion.initializeUI()
  });

  // Combat Settings
  game.settings.registerMenu("dnd5e", "combatConfiguration", {
    name: "SETTINGS.DND5E.COMBAT.Name",
    label: "SETTINGS.DND5E.COMBAT.Label",
    hint: "SETTINGS.DND5E.COMBAT.Hint",
    icon: "fas fa-explosion",
    type: CombatSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "autoRecharge", {
    name: "SETTINGS.DND5E.NPCS.AutoRecharge.Name",
    hint: "SETTINGS.DND5E.NPCS.AutoRecharge.Hint",
    scope: "world",
    config: false,
    default: "no",
    type: String,
    choices: {
      no: "SETTINGS.DND5E.NPCS.AutoRecharge.No",
      silent: "SETTINGS.DND5E.NPCS.AutoRecharge.Silent",
      yes: "SETTINGS.DND5E.NPCS.AutoRecharge.Yes"
    }
  });

  game.settings.register("dnd5e", "autoRollNPCHP", {
    name: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Name",
    hint: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Hint",
    scope: "world",
    config: false,
    default: "no",
    type: String,
    choices: {
      no: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.No",
      silent: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Silent",
      yes: "SETTINGS.DND5E.NPCS.AutoRollNPCHP.Yes"
    }
  });

  game.settings.register("dnd5e", "criticalDamageModifiers", {
    name: "SETTINGS.DND5E.CRITICAL.MultiplyModifiers.Name",
    hint: "SETTINGS.DND5E.CRITICAL.MultiplyModifiers.Hint",
    scope: "world",
    config: false,
    type: Boolean,
    default: false
  });

  game.settings.register("dnd5e", "criticalDamageMaxDice", {
    name: "SETTINGS.DND5E.CRITICAL.MaxDice.Name",
    hint: "SETTINGS.DND5E.CRITICAL.MaxDice.Hint",
    scope: "world",
    config: false,
    type: Boolean,
    default: false
  });

  game.settings.register("dnd5e", "initiativeDexTiebreaker", {
    name: "SETTINGS.DND5E.COMBAT.DexTiebreaker.Name",
    hint: "SETTINGS.DND5E.COMBAT.DexTiebreaker.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean
  });

  game.settings.register("dnd5e", "initiativeScore", {
    name: "SETTINGS.DND5E.COMBAT.InitiativeScore.Name",
    hint: "SETTINGS.DND5E.COMBAT.InitiativeScore.Hint",
    scope: "world",
    config: false,
    default: "none",
    type: String,
    choices: {
      none: "SETTINGS.DND5E.COMBAT.InitiativeScore.None",
      npcs: "SETTINGS.DND5E.COMBAT.InitiativeScore.NPCs",
      all: "SETTINGS.DND5E.COMBAT.InitiativeScore.All"
    }
  });

  // Variant Rules
  game.settings.registerMenu("dnd5e", "variantRulesConfiguration", {
    name: "SETTINGS.DND5E.VARIANT.Name",
    label: "SETTINGS.DND5E.VARIANT.Label",
    hint: "SETTINGS.DND5E.VARIANT.Hint",
    icon: "fas fa-list-check",
    type: VariantRulesSettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "allowFeats", {
    name: "SETTINGS.DND5E.VARIANT.AllowFeats.Name",
    hint: "SETTINGS.DND5E.VARIANT.AllowFeats.Hint",
    scope: "world",
    config: false,
    default: true,
    type: Boolean
  });

  game.settings.register("dnd5e", "currencyWeight", {
    name: "SETTINGS.DND5E.VARIANT.CurrencyWeight.Name",
    hint: "SETTINGS.DND5E.VARIANT.CurrencyWeight.Hint",
    scope: "world",
    config: false,
    default: true,
    type: Boolean
  });

  game.settings.register("dnd5e", "encumbrance", {
    name: "SETTINGS.DND5E.VARIANT.Encumbrance.Name",
    hint: "SETTINGS.DND5E.VARIANT.Encumbrance.Hint",
    scope: "world",
    config: false,
    default: "none",
    type: String,
    choices: {
      none: "SETTINGS.DND5E.VARIANT.Encumbrance.None",
      normal: "SETTINGS.DND5E.VARIANT.Encumbrance.Normal",
      variant: "SETTINGS.DND5E.VARIANT.Encumbrance.Variant"
    }
  });

  game.settings.register("dnd5e", "honorScore", {
    name: "SETTINGS.DND5E.VARIANT.HonorScore.Name",
    hint: "SETTINGS.DND5E.VARIANT.HonorScore.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean,
    requiresReload: true
  });

  game.settings.register("dnd5e", "levelingMode", {
    name: "SETTINGS.DND5E.VARIANT.LevelingMode.Name",
    hint: "SETTINGS.DND5E.VARIANT.LevelingMode.Hint",
    scope: "world",
    config: false,
    default: "xpBoons",
    type: String,
    choices: {
      noxp: "SETTINGS.DND5E.VARIANT.LevelingMode.NoXP",
      xp: "SETTINGS.DND5E.VARIANT.LevelingMode.XP",
      xpBoons: "SETTINGS.DND5E.VARIANT.LevelingMode.XPBoons"
    }
  });

  game.settings.register("dnd5e", "proficiencyModifier", {
    name: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Name",
    hint: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Hint",
    scope: "world",
    config: false,
    default: "bonus",
    type: String,
    choices: {
      bonus: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Bonus",
      dice: "SETTINGS.DND5E.VARIANT.ProficiencyModifier.Dice"
    }
  });

  game.settings.register("dnd5e", "restVariant", {
    name: "SETTINGS.DND5E.VARIANT.Rest.Name",
    hint: "SETTINGS.DND5E.VARIANT.Rest.Hint",
    scope: "world",
    config: false,
    default: "normal",
    type: String,
    choices: {
      normal: "SETTINGS.DND5E.VARIANT.Rest.Normal",
      gritty: "SETTINGS.DND5E.VARIANT.Rest.Gritty",
      epic: "SETTINGS.DND5E.VARIANT.Rest.Epic"
    }
  });

  game.settings.register("dnd5e", "sanityScore", {
    name: "SETTINGS.DND5E.VARIANT.SanityScore.Name",
    hint: "SETTINGS.DND5E.VARIANT.SanityScore.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean,
    requiresReload: true
  });

  // Visibility Settings
  game.settings.registerMenu("dnd5e", "visibilityConfiguration", {
    name: "SETTINGS.DND5E.VISIBILITY.Name",
    label: "SETTINGS.DND5E.VISIBILITY.Label",
    hint: "SETTINGS.DND5E.VISIBILITY.Hint",
    icon: "fas fa-eye",
    type: VisibilitySettingsConfig,
    restricted: true
  });

  game.settings.register("dnd5e", "attackRollVisibility", {
    name: "SETTINGS.DND5E.VISIBILITY.Attack.Name",
    hint: "SETTINGS.DND5E.VISIBILITY.Attack.Hint",
    scope: "world",
    config: false,
    default: "none",
    type: String,
    choices: {
      all: "SETTINGS.DND5E.VISIBILITY.Attack.All",
      hideAC: "SETTINGS.DND5E.VISIBILITY.Attack.HideAC",
      none: "SETTINGS.DND5E.VISIBILITY.Attack.None"
    }
  });

  game.settings.register("dnd5e", "bloodied", {
    name: "SETTINGS.DND5E.BLOODIED.Name",
    hint: "SETTINGS.DND5E.BLOODIED.Hint",
    scope: "world",
    config: false,
    default: "player",
    type: String,
    choices: {
      all: "SETTINGS.DND5E.BLOODIED.All",
      player: "SETTINGS.DND5E.BLOODIED.Player",
      none: "SETTINGS.DND5E.BLOODIED.None"
    }
  });

  game.settings.register("dnd5e", "challengeVisibility", {
    name: "SETTINGS.DND5E.VISIBILITY.Challenge.Name",
    hint: "SETTINGS.DND5E.VISIBILITY.Challenge.Hint",
    scope: "world",
    config: false,
    default: "player",
    type: String,
    choices: {
      all: "SETTINGS.DND5E.VISIBILITY.Challenge.All",
      player: "SETTINGS.DND5E.VISIBILITY.Challenge.Player",
      none: "SETTINGS.DND5E.VISIBILITY.Challenge.None"
    }
  });

  game.settings.register("dnd5e", "concealItemDescriptions", {
    name: "SETTINGS.DND5E.VISIBILITY.ItemDescriptions.Name",
    hint: "SETTINGS.DND5E.VISIBILITY.ItemDescriptions.Hint",
    scope: "world",
    config: false,
    default: false,
    type: Boolean
  });

  // Primary Group
  game.settings.register("dnd5e", "primaryParty", {
    name: "Primary Party",
    scope: "world",
    config: false,
    default: null,
    type: PrimaryPartyData,
    onChange: s => ui.actors.render()
  });

  // Control hints
  game.settings.register("dnd5e", "controlHints", {
    name: "DND5E.Controls.Name",
    hint: "DND5E.Controls.Hint",
    scope: "client",
    config: true,
    type: Boolean,
    default: true
  });

  // NPC sheet default skills
  game.settings.register("dnd5e", "defaultSkills", {
    name: "SETTINGS.DND5E.DEFAULTSKILLS.Name",
    hint: "SETTINGS.DND5E.DEFAULTSKILLS.Hint",
    type: new foundry.data.fields.SetField(
      new foundry.data.fields.StringField({
        choices: () => CONFIG.DND5E.skills
      })
    ),
    default: [],
    config: true
  });
}

/**
 * Data model for tracking information on the primary party.
 *
 * @property {Actor5e} actor  Group actor representing the primary party.
 */
class PrimaryPartyData extends foundry.abstract.DataModel {
  static defineSchema() {
    return { actor: new foundry.data.fields.ForeignDocumentField(foundry.documents.BaseActor) };
  }
}

/* -------------------------------------------- */

/**
 * Register additional settings after modules have had a chance to initialize to give them a chance to modify choices.
 */
function registerDeferredSettings() {
  game.settings.register("dnd5e", "theme", {
    name: "SETTINGS.DND5E.THEME.Name",
    hint: "SETTINGS.DND5E.THEME.Hint",
    scope: "client",
    config: false,
    default: "",
    type: String,
    choices: {
      "": "SHEETS.DND5E.THEME.Automatic",
      ...CONFIG.DND5E.themes
    },
    onChange: s => setTheme(document.body, s)
  });

  matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
    setTheme(document.body, game.settings.get("dnd5e", "theme"));
  });
  matchMedia("(prefers-contrast: more)").addEventListener("change", () => {
    setTheme(document.body, game.settings.get("dnd5e", "theme"));
  });

  // Hook into core color scheme setting.
  const isV13 = game.release.generation >= 13;
  const settingKey = isV13 ? "uiConfig" : "colorScheme";
  const setting = game.settings.get("core", settingKey);
  const settingConfig = game.settings.settings.get(`core.${settingKey}`);
  const { onChange } = settingConfig ?? {};
  if ( onChange ) settingConfig.onChange = (s, ...args) => {
    onChange(s, ...args);
    setTheme(document.body, isV13 ? s.colorScheme : s);
  };
  setTheme(document.body, isV13 ? setting.colorScheme : setting);
}

/* -------------------------------------------- */

/**
 * Set the theme on an element, removing the previous theme class in the process.
 * @param {HTMLElement} element     Body or sheet element on which to set the theme data.
 * @param {string} [theme=""]       Theme key to set.
 * @param {Set<string>} [flags=[]]  Additional theming flags to set.
 */
function setTheme(element, theme="", flags=new Set()) {
  element.className = element.className.replace(/\bdnd5e-(theme|flag)-[\w-]+\b/g, "");

  // Primary Theme
  if ( !theme && (element === document.body) ) {
    if ( matchMedia("(prefers-color-scheme: dark)").matches ) theme = "dark";
    if ( matchMedia("(prefers-color-scheme: light)").matches ) theme = "light";
  }
  if ( theme ) {
    element.classList.add(`dnd5e-theme-${theme.slugify()}`);
    element.dataset.theme = theme;
  }
  else delete element.dataset.theme;

  // Additional Flags
  if ( (element === document.body) && matchMedia("(prefers-contrast: more)").matches ) flags.add("high-contrast");
  for ( const flag of flags ) element.classList.add(`dnd5e-flag-${flag.slugify()}`);
  element.dataset.themeFlags = Array.from(flags).join(" ");
}

var _module$p = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivityChoiceDialog: ActivityChoiceDialog,
  ActivitySheet: ActivitySheet,
  ActivityUsageDialog: ActivityUsageDialog,
  AttackSheet: AttackSheet,
  CastSheet: CastSheet,
  CheckSheet: CheckSheet,
  DamageSheet: DamageSheet,
  EnchantSheet: EnchantSheet,
  EnchantUsageDialog: EnchantUsageDialog,
  ForwardSheet: ForwardSheet,
  HealSheet: HealSheet,
  OrderUsageDialog: OrderUsageDialog,
  SaveSheet: SaveSheet,
  SummonSheet: SummonSheet,
  SummonUsageDialog: SummonUsageDialog,
  UtilitySheet: UtilitySheet
});

/**
 * A specialized subclass of ContextMenu that places the menu in a fixed position.
 * @extends {ContextMenu}
 */
class ContextMenu5e extends (foundry.applications?.ui?.ContextMenu ?? ContextMenu) {
  /** @override */
  _setPosition(html, target, options={}) {
    if ( game.release.generation > 12 ) {
      html.classList.add("dnd5e2");
      return this._setFixedPosition(html, target, options);
    } else {
      html = html[0];
      target = target[0];
    }
    document.body.appendChild(html);
    const { clientWidth, clientHeight } = document.documentElement;
    const { width, height } = html.getBoundingClientRect();

    // TODO: Improve core ContextMenu class to provide this event rather than using the global event.
    const { clientX, clientY } = window.event;
    const left = Math.min(clientX, clientWidth - width);
    this._expandUp = clientY + height > clientHeight;
    html.classList.add("dnd5e2");
    html.classList.toggle("expand-up", this._expandUp);
    html.classList.toggle("expand-down", !this._expandUp);
    html.style.visibility = "";
    html.style.left = `${left}px`;
    if ( this._expandUp ) html.style.bottom = `${clientHeight - clientY}px`;
    else html.style.top = `${clientY}px`;
    target.classList.add("context");
    const theme = target.closest("[data-theme]")?.dataset.theme ?? "";
    setTheme(html, theme);
  }
}

/**
 * Custom element that handles displaying active effects lists.
 */
class EffectsElement extends HTMLElement {
  connectedCallback() {
    this.#app = ui.windows[this.closest(".app")?.dataset.appid];

    for ( const control of this.querySelectorAll("[data-action]") ) {
      control.addEventListener("click", event => {
        this._onAction(event.currentTarget, event.currentTarget.dataset.action);
      });
    }

    for ( const source of this.querySelectorAll(".effect-source a") ) {
      source.addEventListener("click", this._onClickEffectSource.bind(this));
    }

    for ( const control of this.querySelectorAll("[data-context-menu]") ) {
      control.addEventListener("click", event => {
        event.preventDefault();
        event.stopPropagation();
        const { clientX, clientY } = event;
        event.currentTarget.closest("[data-effect-id]").dispatchEvent(new PointerEvent("contextmenu", {
          view: window, bubbles: true, cancelable: true, clientX, clientY
        }));
      });
    }

    const MenuCls = this.hasAttribute("v2") ? ContextMenu5e : ContextMenu;
    new MenuCls(this, "[data-effect-id]", [], { onOpen: element => {
      const effect = this.getEffect(element.dataset);
      if ( !effect ) return;
      ui.context.menuItems = this._getContextOptions(effect);
      Hooks.call("dnd5e.getActiveEffectContextOptions", effect, ui.context.menuItems);
    }, jQuery: true });
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Reference to the application that contains this component.
   * @type {Application}
   */
  #app;

  /**
   * Reference to the application that contains this component.
   * @type {Application}
   * @protected
   */
  get _app() { return this.#app; }

  /* -------------------------------------------- */

  /**
   * Document whose effects are represented.
   * @type {Actor5e|Item5e}
   */
  get document() {
    return this._app.document;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the data structure for Active Effects which are currently applied to an Actor or Item.
   * @param {ActiveEffect5e[]} effects         The array of Active Effect instances for which to prepare sheet data.
   * @param {object} [options={}]
   * @param {Actor5e|Item5e} [options.parent]  Document that owns these active effects.
   * @returns {object}                  Data for rendering.
   */
  static prepareCategories(effects, { parent }={}) {
    // Define effect header categories
    const categories = {
      enchantment: {
        type: "enchantment",
        label: game.i18n.localize("DND5E.ENCHANTMENT.Category.General"),
        effects: [],
        isEnchantment: true
      },
      temporary: {
        type: "temporary",
        label: game.i18n.localize("DND5E.EffectTemporary"),
        effects: []
      },
      enchantmentActive: {
        type: "activeEnchantment",
        label: game.i18n.localize("DND5E.ENCHANTMENT.Category.Active"),
        effects: [],
        isEnchantment: true
      },
      passive: {
        type: "passive",
        label: game.i18n.localize("DND5E.EffectPassive"),
        effects: []
      },
      enchantmentInactive: {
        type: "inactiveEnchantment",
        label: game.i18n.localize("DND5E.ENCHANTMENT.Category.Inactive"),
        effects: [],
        isEnchantment: true
      },
      inactive: {
        type: "inactive",
        label: game.i18n.localize("DND5E.EffectInactive"),
        effects: []
      },
      suppressed: {
        type: "suppressed",
        label: game.i18n.localize("DND5E.EffectUnavailable"),
        effects: [],
        disabled: true,
        info: [game.i18n.localize("DND5E.EffectUnavailableInfo")]
      }
    };

    // Iterate over active effects, classifying them into categories
    for ( const e of effects ) {
      if ( (e.parent.system?.identified === false) && !game.user.isGM ) continue;
      if ( e.isAppliedEnchantment ) {
        if ( e.disabled ) categories.enchantmentInactive.effects.push(e);
        else categories.enchantmentActive.effects.push(e);
      }
      else if ( e.type === "enchantment" ) categories.enchantment.effects.push(e);
      else if ( e.isSuppressed ) categories.suppressed.effects.push(e);
      else if ( e.disabled ) categories.inactive.effects.push(e);
      else if ( e.isTemporary ) categories.temporary.effects.push(e);
      else categories.passive.effects.push(e);
    }
    categories.enchantment.hidden = !parent?.system.isEnchantment;
    categories.enchantmentActive.hidden = !categories.enchantmentActive.effects.length;
    categories.enchantmentInactive.hidden = !categories.enchantmentInactive.effects.length;
    categories.suppressed.hidden = !categories.suppressed.effects.length;

    for ( const category of Object.values(categories) ) {
      category.localizationPrefix = category.isEnchantment ? "DND5E.ENCHANTMENT.Action." : "DND5E.Effect";
    }

    return categories;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Prepare an array of context menu options which are available for owned ActiveEffect documents.
   * @param {ActiveEffect5e} effect  The ActiveEffect for which the context menu is activated.
   * @returns {ContextMenuEntry[]}   An array of context menu options offered for the ActiveEffect.
   * @protected
   */
  _getContextOptions(effect) {
    const isConcentrationEffect = (this.document instanceof Actor5e) && this._app._concentration?.effects.has(effect);
    const options = [
      {
        name: "DND5E.ContextMenuActionEdit",
        icon: "<i class='fas fa-edit fa-fw'></i>",
        condition: () => effect.isOwner,
        callback: li => this._onAction(li[0], "edit")
      },
      {
        name: "DND5E.ContextMenuActionDuplicate",
        icon: "<i class='fas fa-copy fa-fw'></i>",
        condition: () => effect.isOwner,
        callback: li => this._onAction(li[0], "duplicate")
      },
      {
        name: "DND5E.ContextMenuActionDelete",
        icon: "<i class='fas fa-trash fa-fw'></i>",
        condition: () => effect.isOwner && !isConcentrationEffect,
        callback: li => this._onAction(li[0], "delete")
      },
      {
        name: effect.disabled ? "DND5E.ContextMenuActionEnable" : "DND5E.ContextMenuActionDisable",
        icon: effect.disabled ? "<i class='fas fa-check fa-fw'></i>" : "<i class='fas fa-times fa-fw'></i>",
        group: "state",
        condition: () => effect.isOwner && !isConcentrationEffect,
        callback: li => this._onAction(li[0], "toggle")
      },
      {
        name: "DND5E.ConcentrationBreak",
        icon: '<dnd5e-icon src="systems/dnd5e/icons/svg/break-concentration.svg"></dnd5e-icon>',
        condition: () => isConcentrationEffect,
        callback: () => this.document.endConcentration(effect),
        group: "state"
      }
    ];

    // Toggle Favorite State
    if ( (this.document instanceof Actor5e) && ("favorites" in this.document.system) ) {
      const uuid = effect.getRelativeUUID(this.document);
      const isFavorited = this.document.system.hasFavorite(uuid);
      options.push({
        name: isFavorited ? "DND5E.FavoriteRemove" : "DND5E.Favorite",
        icon: "<i class='fas fa-bookmark fa-fw'></i>",
        condition: () => effect.isOwner,
        callback: li => this._onAction(li[0], isFavorited ? "unfavorite" : "favorite"),
        group: "state"
      });
    }

    return options;
  }

  /* -------------------------------------------- */

  /**
   * Handle effects actions.
   * @param {Element} target  Button or context menu entry that triggered this action.
   * @param {string} action   Action being triggered.
   * @returns {Promise}
   * @protected
   */
  async _onAction(target, action) {
    const event = new CustomEvent("effect", {
      bubbles: true,
      cancelable: true,
      detail: action
    });
    if ( target.dispatchEvent(event) === false ) return;

    if ( action === "toggleCondition" ) {
      return this._onToggleCondition(target.closest("[data-condition-id]")?.dataset.conditionId);
    }

    const dataset = target.closest("[data-effect-id]")?.dataset;
    const effect = this.getEffect(dataset);
    if ( (action !== "create") && !effect ) return;

    switch ( action ) {
      case "create":
        return this._onCreate(target);
      case "delete":
        await effect.deleteDialog({}, { render: false });
        return this.#app.render();
      case "duplicate":
        return effect.clone({name: game.i18n.format("DOCUMENT.CopyOf", {name: effect.name})}, {save: true});
      case "edit":
        return effect.sheet.render(true);
      case "favorite":
        return this.document.system.addFavorite({type: "effect", id: effect.getRelativeUUID(this.document)});
      case "toggle":
        return effect.update({disabled: !effect.disabled});
      case "unfavorite":
        return this.document.system.removeFavorite(effect.getRelativeUUID(this.document));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a condition.
   * @param {string} conditionId  The condition identifier.
   * @returns {Promise}
   * @protected
   */
  async _onToggleCondition(conditionId) {
    const existing = this.document.effects.get(staticID(`dnd5e${conditionId}`));
    if ( existing ) return existing.delete();
    const effect = await ActiveEffect.implementation.fromStatusEffect(conditionId);
    return ActiveEffect.implementation.create(effect, { parent: this.document, keepId: true });
  }

  /* -------------------------------------------- */

  /**
   * Create a new effect.
   * @param {HTMLElement} target  Button that triggered this action.
   * @returns {Promise<ActiveEffect5e>}
   */
  async _onCreate(target) {
    const li = target.closest("li");
    const isActor = this.document instanceof Actor;
    const isEnchantment = li.dataset.effectType.startsWith("enchantment");
    return this.document.createEmbeddedDocuments("ActiveEffect", [{
      type: isEnchantment ? "enchantment" : "base",
      name: isActor ? game.i18n.localize("DND5E.EffectNew") : this.document.name,
      icon: isActor ? "icons/svg/aura.svg" : this.document.img,
      origin: isEnchantment ? undefined : this.document.uuid,
      "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined,
      disabled: ["inactive", "enchantmentInactive"].includes(li.dataset.effectType)
    }]);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking an effect's source.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  async _onClickEffectSource(event) {
    const { uuid } = event.currentTarget.dataset;
    const doc = await fromUuid(uuid);
    if ( !doc ) return;
    if ( !doc.testUserPermission(game.user, "LIMITED") ) {
      ui.notifications.warn("DND5E.DocumentViewWarn", { localize: true });
      return;
    }
    doc.sheet.render(true);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Fetch an effect from this document, or any embedded items if this document is an actor.
   * @param {object} data
   * @param {string} data.effectId    ID of the effect to fetch.
   * @param {string} [data.parentId]  ID of the parent item containing the effect.
   * @returns {ActiveEffect5e}
   */
  getEffect({ effectId, parentId }={}) {
    if ( !parentId ) return this.document.effects.get(effectId);
    return this.document.items.get(parentId).effects.get(effectId);
  }
}

const { DocumentSheetV2 } = foundry.applications.api;

/**
 * Base document sheet from which all document-based application should be based.
 */
class DocumentSheet5e extends ApplicationV2Mixin(DocumentSheetV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["standard-form"]
  };
}

/**
 * Base document sheet from which all actor configuration sheets should be based.
 */
let BaseConfigSheet$1 = class BaseConfigSheet extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["config-sheet"],
    sheetConfig: false,
    form: {
      submitOnChange: true
    }
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.advantageModeOptions = [
      { value: -1, label: game.i18n.localize("DND5E.Disadvantage") },
      { value: 0, label: game.i18n.localize("DND5E.Normal") },
      { value: 1, label: game.i18n.localize("DND5E.Advantage") }
    ];
    return context;
  }
};

/**
 * Configuration application for an actor or species's movement & senses.
 */
class MovementSensesConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    type: null,
    keyPath: null,
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/shared/config/movement-senses-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Path to the movement or senses data on the document.
   * @type {string}
   */
  get keyPath() {
    let keyPath = this.options.keyPath ?? `${this.document instanceof Actor ? "attributes." : ""}${this.options.type}`;
    if ( keyPath.startsWith("system.") ) keyPath = keyPath.slice(7);
    return keyPath;
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize(this.options.type === "movement" ? "DND5E.Movement" : "DND5E.Senses");
  }

  /* -------------------------------------------- */

  /**
   * Specific types measured, depending on trait type and actor type.
   * @type {Record<string, string>}
   */
  get types() {
    if ( this.options.type === "senses" ) return Object.keys(CONFIG.DND5E.senses);
    if ( this.document.type === "group" ) return ["land", "water", "air"];
    return Object.keys(CONFIG.DND5E.movementTypes);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    options.uniqueId = `${options.type}-${options.document.uuid}`.replace(/\./g, "-");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;
    const placeholderData = this.document.system.details?.race?.system?.[this.options.type] ?? null;

    context.data = foundry.utils.getProperty(source, this.keyPath) ?? {};
    context.fields = this.document.system.schema.getField(this.keyPath).fields;
    context.extras = this._prepareExtraFields(context);
    context.types = this.types.map(key => ({
      field: context.fields[key],
      value: context.data[key],
      placeholder: placeholderData?.[key] ?? ""
    }));

    context.unitsOptions = Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => ({ value, label }));
    if ( (this.document.type === "pc") || ((this.document.type === "npc") && placeholderData) ) {
      const automaticUnit = CONFIG.DND5E.movementUnits[placeholderData?.units ?? defaultUnits("length")]?.label ?? "";
      context.unitsOptions.unshift(
        { value: "", label: game.i18n.format("DND5E.AutomaticValue", { value: automaticUnit.toLowerCase() }) },
        { rule: true }
      );
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the additional fields listed in the form.
   * @param {ApplicationRenderContext} context  Context being prepared.
   * @returns {object[]}
   * @protected
   */
  _prepareExtraFields(context) {
    const extras = [];
    if ( context.fields.hover ) extras.push({
      field: context.fields.hover,
      input: context.inputs.createCheckboxInput,
      value: context.data.hover
    });
    return extras;
  }
}

/**
 * Configuration application for an actor's creature type.
 */
class CreatureTypeConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["creature-type"],
    keyPath: "details.type",
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/shared/config/creature-type-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.CreatureType");
  }

  /* -------------------------------------------- */

  /**
   * Return a reference to the Actor. Either the NPCs themselves if they are being edited, otherwise the parent Actor
   * if a race Item is being edited.
   * @returns {Actor5e}
   */
  get actor() {
    return this.object.actor ?? this.object;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;

    context.data = foundry.utils.getProperty(source, this.options.keyPath) ?? {};
    context.fields = this.document.system.schema.getField(this.options.keyPath).fields;
    context.keyPath = `system.${this.options.keyPath}`;

    context.swarmOptions = [
      { value: "", label: "" },
      ...Object.entries(CONFIG.DND5E.actorSizes).map(([value, { label }]) => ({ value, label })).reverse()
    ];
    context.typeOptions = Object.entries(CONFIG.DND5E.creatureTypes)
      .map(([value, { label }]) => ({ value, label, selected: context.data.value === value }));
    if ( context.fields.custom ) context.custom = {
      enabled: true,
      selected: context.data.value === "custom"
    };
    context.rows = Math.ceil((context.typeOptions.length + (context.custom.enabled ? 1 : 0)) / 2);
    context.preview = Actor5e.formatCreatureType(context.data);

    return context;
  }
}

/**
 * An abstract class containing common functionality between actor sheet configuration apps.
 * @extends {DocumentSheet}
 * @abstract
 */
class BaseConfigSheet extends DocumentSheet {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      sheetConfig: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    if ( this.isEditable ) {
      for ( const override of this._getActorOverrides() ) {
        html.find(`input[name="${override}"],select[name="${override}"]`).each((i, el) => {
          el.disabled = true;
          el.dataset.tooltip = "DND5E.ActiveEffectOverrideWarning";
        });
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the list of fields that are currently modified by Active Effects on the Actor.
   * @returns {string[]}
   * @protected
   */
  _getActorOverrides() {
    return Object.keys(foundry.utils.flattenObject(this.object.overrides || {}));
  }

  /* -------------------------------------------- */

  /**
   * Helper method to add choices that have been overridden.
   * @param {string} prefix       The initial form prefix under which the choices are grouped.
   * @param {string} path         Path in actor data.
   * @param {string[]} overrides  The list of fields that are currently modified by Active Effects. *Will be mutated.*
   * @internal
   */
  _addOverriddenChoices(prefix, path, overrides) {
    ActiveEffect5e.addOverriddenChoices(this.document, prefix, path, overrides);
  }
}

const { BooleanField: BooleanField$d } = foundry.data.fields;

/**
 * An application class which provides advanced configuration for special character flags which modify an Actor.
 */
class ActorSheetFlags extends BaseConfigSheet {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorSheetFlags` application has been deprecated and replaced with a tab on the character sheet.",
      { since: "DnD5e 4.3", until: "DnD5e 4.5" }
    );
    super(...args);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      id: "actor-flags",
      classes: ["dnd5e"],
      template: "systems/dnd5e/templates/apps/actor-flags.hbs",
      width: 500,
      closeOnSubmit: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return `${game.i18n.localize("DND5E.FlagsTitle")}: ${this.object.name}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData() {
    const data = {};
    data.actor = this.object;
    data.classes = this._getClasses();
    data.flags = this._getFlags();
    data.bonuses = this._getBonuses();
    if ( this.document.type === "npc" ) data.npc = this._getNPC();
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Prepare an object of sorted classes.
   * @returns {object}
   * @private
   */
  _getClasses() {
    const classes = this.object.items.filter(i => i.type === "class");
    return classes.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)).reduce((obj, i) => {
      obj[i.id] = i.name;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Prepare an object of flags data which groups flags by section
   * Add some additional data for rendering
   * @returns {object}
   * @private
   */
  _getFlags() {
    const flags = {};
    const baseData = this.document.toJSON();
    for ( let [k, v] of Object.entries(CONFIG.DND5E.characterFlags) ) {
      if ( !flags.hasOwnProperty(v.section) ) flags[v.section] = {};
      let flag = foundry.utils.deepClone(v);
      flag.type = v.type.name;
      flag.isCheckbox = v.type === Boolean;
      flag.isSelect = v.hasOwnProperty("choices");
      flag.value = foundry.utils.getProperty(baseData.flags, `dnd5e.${k}`);
      flags[v.section][`flags.dnd5e.${k}`] = flag;
    }
    return flags;
  }

  /* -------------------------------------------- */

  /**
   * Get the bonuses fields and their localization strings
   * @returns {Array<object>}
   * @private
   */
  _getBonuses() {
    const src = this.object.toObject();
    const bonuses = [
      {name: "system.bonuses.mwak.attack", label: "DND5E.BONUSES.FIELDS.bonuses.mwak.attack.label"},
      {name: "system.bonuses.mwak.damage", label: "DND5E.BONUSES.FIELDS.bonuses.mwak.damage.label"},
      {name: "system.bonuses.rwak.attack", label: "DND5E.BONUSES.FIELDS.bonuses.rwak.attack.label"},
      {name: "system.bonuses.rwak.damage", label: "DND5E.BONUSES.FIELDS.bonuses.rwak.damage.label"},
      {name: "system.bonuses.msak.attack", label: "DND5E.BONUSES.FIELDS.bonuses.msak.attack.label"},
      {name: "system.bonuses.msak.damage", label: "DND5E.BONUSES.FIELDS.bonuses.msak.damage.label"},
      {name: "system.bonuses.rsak.attack", label: "DND5E.BONUSES.FIELDS.bonuses.rsak.attack.label"},
      {name: "system.bonuses.rsak.damage", label: "DND5E.BONUSES.FIELDS.bonuses.rsak.damage.label"},
      {name: "system.bonuses.abilities.check", label: "DND5E.BONUSES.FIELDS.bonuses.abilities.check.label"},
      {name: "system.bonuses.abilities.save", label: "DND5E.BONUSES.FIELDS.bonuses.abilities.save.label"},
      {name: "system.bonuses.abilities.skill", label: "DND5E.BONUSES.FIELDS.bonuses.abilities.skill.label"},
      {name: "system.bonuses.spell.dc", label: "DND5E.BONUSES.FIELDS.bonuses.spell.dc.label"}
    ];
    for ( let b of bonuses ) {
      b.value = foundry.utils.getProperty(src, b.name) || "";
    }
    return bonuses;
  }

  /* -------------------------------------------- */

  /**
   * Get NPC-specific fields.
   * @returns {object}
   * @protected
   */
  _getNPC() {
    return {
      important: {
        field: new BooleanField$d({
          label: "DND5E.NPC.FIELDS.traits.important.label", hint: "DND5E.NPC.FIELDS.traits.important.hint"
        }),
        name: "system.traits.important",
        value: this.document.system._source.traits.important
      }
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    const actor = this.object;
    let updateData = foundry.utils.expandObject(formData);
    const src = actor.toObject();

    // Unset any flags which are "false"
    const flags = updateData.flags.dnd5e;
    for ( let [k, v] of Object.entries(flags) ) {
      if ( [undefined, null, "", false, 0].includes(v) ) {
        delete flags[k];
        if ( foundry.utils.hasProperty(src.flags, `dnd5e.${k}`) ) flags[`-=${k}`] = null;
      }
    }

    // Clear any bonuses which are whitespace only
    for ( let b of Object.values(updateData.system.bonuses ) ) {
      for ( let [k, v] of Object.entries(b) ) {
        b[k] = v.trim();
      }
    }

    // Diff the data against any applied overrides and apply
    await actor.update(updateData, {diff: false});
  }
}

/**
 * Application for configuring the source data on actors and items.
 */
class SourceConfig extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["source-config", "standard-form"],
    sheetConfig: false,
    position: {
      width: 400
    },
    form: {
      closeOnSubmit: true
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    source: {
      template: "systems/dnd5e/templates/apps/source-config.hbs"
    },
    footer: {
      template: "templates/generic/form-footer.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.SOURCE.Action.Configure");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const source = this.document.system.toObject();
    context.buttons = [{ icon: "fa-regular fa-save", label: "Save", type: "Save" }];
    context.data = foundry.utils.getProperty(this.document, this.options.keyPath);
    context.fields = this.document.system.schema.getField("source").fields;
    context.keyPath = this.options.keyPath;
    context.source = source.source;
    context.sourceUuid = this.document._stats.compendiumSource;
    context.sourceAnchor = (await fromUuid(context.sourceUuid))?.toAnchor().outerHTML;
    context.rulesVersions = [
      { value: "", label: "" },
      { value: "2024", label: game.i18n.localize("SETTINGS.DND5E.RULESVERSION.Modern") },
      { value: "2014", label: game.i18n.localize("SETTINGS.DND5E.RULESVERSION.Legacy") }
    ];
    if ( this.document.system.hasOwnProperty("identifier") ) context.identifier = {
      field: this.document.system.schema.getField("identifier"),
      placeholder: this.document.identifier,
      value: source.identifier
    };
    return context;
  }
}

/**
 * @import { DropEffectValue } from "../../drag-drop.mjs"
 */

/**
 * Adds drop behavior functionality to all sheets.
 * @param {typeof Application} Base  The base class being mixed.
 * @returns {typeof DragDropApplication}
 */
function DragDropApplicationMixin(Base) {
  return class DragDropApplication extends Base {
    /** @override */
    _onDragOver(event) {
      const data = DragDrop.getPayload(event);
      DragDrop.dropEffect = event.dataTransfer.dropEffect = (foundry.utils.getType(data) === "Object")
        ? this._dropBehavior(event, data) : "copy";
    }

    /* -------------------------------------------- */

    /**
     * The behavior for the dropped data.
     * @param {DragEvent} event  The drag event.
     * @param {object} data      The drag payload.
     * @returns {DropEffectValue}
     */
    _dropBehavior(event, data) {
      const allowed = this._allowedDropBehaviors(event, data);
      let behavior = DragDrop.dropEffect ?? event.dataTransfer?.dropEffect;

      if ( event.type === "dragover" ) {
        if ( areKeysPressed(event, "dragMove") ) behavior = "move";
        else if ( areKeysPressed(event, "dragCopy") ) behavior = "copy";
        else behavior = this._defaultDropBehavior(event, data);
      }

      if ( (behavior !== "none") && !allowed.has(behavior) ) return allowed.first() ?? "none";
      return behavior || "copy";
    }

    /* -------------------------------------------- */

    /**
     * Types of allowed drop behaviors based on the origin & target of a drag event.
     * @param {DragEvent} event  The drag event.
     * @param {object} data      The drag payload.
     * @returns {Set<DropEffectValue>}
     * @protected
     */
    _allowedDropBehaviors(event, data) {
      return new Set();
    }

    /* -------------------------------------------- */

    /**
     * Determine the default drop behavior for the provided operation.
     * @param {DragEvent} event  The drag event.
     * @param {object} data      The drag payload.
     * @returns {DropEffectValue}
     * @protected
     */
    _defaultDropBehavior(event, data) {
      return "copy";
    }
  };
}

/**
 * Mixin method for common uses between all actor sheets.
 * @param {typeof Application} Base   Application class being extended.
 * @returns {class}
 * @mixin
 */
function ActorSheetMixin(Base) {
  return class ActorSheet extends DragDropApplicationMixin(Base) {

    /**
     * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs.
     * @param {Event} event  Triggering event.
     * @protected
     */
    _onChangeInputDelta(event) {
      const input = event.target;
      const target = this.actor.items.get(input.closest("[data-item-id]")?.dataset.itemId) ?? this.actor;
      const { activityId } = input.closest("[data-activity-id]")?.dataset ?? {};
      const activity = target?.system.activities?.get(activityId);
      const result = parseInputDelta(input, activity ?? target);
      if ( result !== undefined ) {
        // Special case handling for Item uses.
        if ( input.dataset.name === "system.uses.value" ) {
          target.update({ "system.uses.spent": target.system.uses.max - result });
        } else if ( activity && (input.dataset.name === "uses.value") ) {
          target.updateActivity(activityId, { "uses.spent": activity.uses.max - result });
        }
        else target.update({ [input.dataset.name]: result });
      }
    }

    /* -------------------------------------------- */

    /**
     * Stack identical consumables when a new one is dropped rather than creating a duplicate item.
     * @param {object} itemData                  The item data requested for creation.
     * @param {object} [options={}]
     * @param {string} [options.container=null]  ID of the container into which this item is being dropped.
     * @returns {Promise<Item5e>|null}           If a duplicate was found, returns the adjusted item stack.
     */
    _onDropStackConsumables(itemData, { container=null }={}) {
      const droppedSourceId = itemData._stats?.compendiumSource ?? itemData.flags.core?.sourceId;
      if ( itemData.type !== "consumable" || !droppedSourceId ) return null;
      const similarItem = this.actor.sourcedItems.get(droppedSourceId, { legacy: false })
        ?.filter(i => (i.system.container === container) && (i.name === itemData.name))?.first();
      if ( !similarItem ) return null;
      return similarItem.update({
        "system.quantity": similarItem.system.quantity + Math.max(itemData.system.quantity, 1)
      });
    }

    /* -------------------------------------------- */
    /*  Drag & Drop                                 */
    /* -------------------------------------------- */

    /** @override */
    _allowedDropBehaviors(event, data) {
      if ( !data.uuid ) return new Set(["copy"]);
      const allowed = new Set(["copy", "move"]);
      const s = foundry.utils.parseUuid(data.uuid);
      const t = foundry.utils.parseUuid(this.document.uuid);
      const sCompendium = s.collection instanceof CompendiumCollection;
      const tCompendium = t.collection instanceof CompendiumCollection;

      // If either source or target are within a compendium, but not inside the same compendium, move not allowed
      if ( (sCompendium || tCompendium) && (s.collection !== t.collection) ) allowed.delete("move");

      return allowed;
    }

    /* -------------------------------------------- */

    /** @override */
    _defaultDropBehavior(event, data) {
      if ( !data.uuid ) return "copy";
      const d = foundry.utils.parseUuid(data.uuid);
      const t = foundry.utils.parseUuid(this.document.uuid);
      const base = d.embedded?.length ? "document" : "primary";
      return (d.collection === t.collection) && (d[`${base}Id`] === t[`${base}Id`])
        && (d[`${base}Type`] === t[`${base}Type`]) ? "move" : "copy";
    }
  };
}

/**
 * Base application for configuring an actor's abilities, skills, or tools.
 */
class BaseProficiencyConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    key: null,
    trait: null,
    position: {
      width: 500
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Configuration data for the ability being edited.
   * @type {object}
   * @abstract
   */
  get propertyConfig() {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Label for the specific skill or tool being configured.
   * @type {string}
   */
  get propertyLabel() {
    return keyLabel(this.options.key, { trait: this.options.trait });
  }

  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.format("DND5E.ABILITY.Configure.Title", { ability: this.propertyLabel });
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    options.uniqueId = `${options.trait}-${options.key}-${options.document.uuid}`.replace(/\./g, "-");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;
    const keyPath = actorKeyPath(this.options.trait).replace("system.", "");

    context.data = foundry.utils.getProperty(source, `${keyPath}.${this.options.key}`) ?? {};
    context.fields = this.document.system.schema.getField(keyPath).model.fields;
    context.label = this.propertyLabel;
    context.prefix = `system.${keyPath}.${this.options.key}.`;

    if ( this.document.system.bonuses?.abilities ) context.global = {
      data: source.bonuses?.abilities ?? {},
      fields: this.document.system.schema.fields.bonuses.fields.abilities.fields
    };

    return context;
  }
}

/**
 * Configuration application for an actor's abilities.
 */
class AbilityConfig extends BaseProficiencyConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    trait: "saves"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/ability-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get propertyConfig() {
    return CONFIG.DND5E.abilities[this.options.key];
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.proficiencyOptions = [
      { value: 0, label: CONFIG.DND5E.proficiencyLevels[0] },
      { value: 1, label: CONFIG.DND5E.proficiencyLevels[1] }
    ];
    return context;
  }
}

/**
 * Configuration application for armor class calculation.
 */
class ArmorClassConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["armor-class"],
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/armor-class-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.ArmorClass");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.data = this.document.system.attributes.ac;
    context.fields = this.document.system.schema.fields.attributes.fields.ac.fields;
    context.source = this.document.system._source.attributes.ac;

    context.calculationOptions = Object.entries(CONFIG.DND5E.armorClasses).reduce((arr, [value, config]) => {
      if ( value === "custom" ) arr.push({ rule: true });
      arr.push({ value, label: config.label });
      if ( value === "natural" ) arr.push({ rule: true });
      return arr;
    }, []);

    const config = CONFIG.DND5E.armorClasses[context.source.calc];
    context.formula = {
      disabled: context.source.calc !== "custom",
      showFlat: ["flat", "natural"].includes(context.source.calc),
      value: (context.source.calc === "custom" ? context.source.formula : config?.formula) ?? ""
    };

    if ( context.formula.value.includes("@attributes.ac.dex") ) context.dexterity = context.data.dex;

    context.calculations = [];
    if ( context.formula.value.includes("@attributes.ac.armor") ) {
      for ( const key of ["armor", "shield"] ) {
        const item = context.data[`equipped${key.capitalize()}`];
        if ( !item ) continue;
        const val = item.system.armor.value - (item.system.magicAvailable ? (item.system.armor.magicalBonus ?? 0) : 0);
        context.calculations.push({
          anchor: item.toAnchor().outerHTML,
          img: item.img,
          magicalBonus: item.system.properties.has("mgc")
            ? formatNumber(item.system.armor.magicalBonus, { signDisplay: "always" }) : "—",
          name: item.name,
          value: formatNumber(val, { signDisplay: key === "shield" ? "always" : "auto" })
        });
      }
    }
    if ( context.source.calc !== "flat" ) {
      for ( const bonus of this.document._prepareActiveEffectAttributions("system.attributes.ac.bonus") ) {
        if ( bonus.mode !== CONST.ACTIVE_EFFECT_MODES.ADD ) continue;
        context.calculations.push({
          anchor: bonus.document.toAnchor().outerHTML,
          img: bonus.document.img,
          magicalBonus: formatNumber(bonus.value, { signDisplay: "always" }),
          name: bonus.label,
          value: "—"
        });
      }
    }

    return context;
  }
}

/**
 * Configuration application for an actor's concentration checks.
 */
class ConcentrationConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    ability: null,
    position: {
      width: 500
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/concentration-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.format("DND5E.ABILITY.Configure.Title", { ability: game.i18n.localize("DND5E.Concentration") });
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;

    context.data = source.attributes?.concentration ?? {};
    context.fields = this.document.system.schema.fields.attributes.fields.concentration.fields;
    const ability = CONFIG.DND5E.abilities[CONFIG.DND5E.defaultAbilities.concentration]?.label?.toLowerCase();
    context.abilityOptions = [
      { value: "", label: ability ? game.i18n.format("DND5E.DefaultSpecific", { default: ability }) : "" },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];

    if ( this.document.system.bonuses?.abilities ) context.global = {
      data: source.bonuses?.abilities ?? {},
      fields: this.document.system.schema.fields.bonuses.fields.abilities.fields
    };

    return context;
  }
}

/**
 * Configuration application for an actor's concentration checks.
 */
class DeathConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    ability: null,
    position: {
      width: 500
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/death-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.DeathSaveConfigure");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document.system._source;

    context.data = source.attributes?.death ?? {};
    context.fields = this.document.system.schema.getField("attributes.death").fields;

    if ( this.document.system.bonuses?.abilities ) context.global = {
      data: source.bonuses?.abilities ?? {},
      fields: this.document.system.schema.getField("bonuses.abilities").fields
    };

    return context;
  }
}

/**
 * Base application for selecting an actor's proficiencies.
 */
class TraitsConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["trait-selector"],
    trait: null,
    position: {
      width: 600
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/traits-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return traitLabel(this.options.trait);
  }

  /* -------------------------------------------- */

  /**
   * Label used for the "other" category.
   * @type {string}
   */
  get otherLabel() {
    return game.i18n.localize("DND5E.ProficiencyOther");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    options = super._initializeApplicationOptions(options);
    if ( !CONFIG.DND5E.traits[options.trait] ) throw new Error(
      `Cannot instantiate TraitsConfig with a trait not defined in CONFIG.DND5E.traits: ${options.trait}.`
    );
    options.uniqueId = `${options.trait}-${options.document.uuid}`.replace(/\./g, "-");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.keyPath = actorKeyPath(this.options.trait);
    context.data = foundry.utils.getProperty(this.document._source, context.keyPath);
    context.checkbox = new foundry.data.fields.BooleanField();
    const chosen = new Set(
      filteredKeys(await actorValues(this.document, this.options.trait)).map(k => k.split(":").pop())
    );
    context.choices = await choices(this.options.trait, { chosen });
    context.fields = actorFields(this.document, this.options.trait);

    // Handle custom traits not in a top-level category
    const other = {
      label: this.otherLabel,
      children: new SelectChoices(),
      otherGroup: true
    };
    for ( const [key, choice] of Object.entries(context.choices) ) {
      if ( choice.children ) continue;
      other.children[key] = choice;
      delete context.choices[key];
    }
    if ( !foundry.utils.isEmpty(other.children) ) context.choices.OTHER = other;
    this._processChoices(context.data, context.choices);

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Processes the choices to ensure that children are checked if the category is checked and that
   * masteries are only enabled if character has proficiency.
   * @param {object} data                     Traits data.
   * @param {SelectChoices} choices           Choices object.
   * @param {boolean} [categoryChosen=false]  Is the category above this one selected?
   * @protected
   */
  _processChoices(data, choices, categoryChosen=false) {
    for ( const [key, choice] of Object.entries(choices) ) {
      this._processChoice(data, key, choice, categoryChosen);
      if ( choice.children ) this._processChoices(data, choice.children, choice.chosen && (key !== "OTHER"));
    }
  }

  /* -------------------------------------------- */

  /**
   * Perform any modification on a choice.
   * @param {object} data                     Traits data.
   * @param {string} key                      Choice key.
   * @param {object} choice                   Data for the choice.
   * @param {boolean} [categoryChosen=false]  Is the category above this one selected?
   * @protected
   */
  _processChoice(data, key, choice, categoryChosen=false) {
    if ( (data.value?.includes("ALL") && (key !== "ALL")) || categoryChosen ) {
      choice.chosen = true;
      choice.disabled = true;
    }
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    if ( CONFIG.DND5E.traits[this.options.trait].dataType !== Number ) {
      this._filterData(submitData, `${actorKeyPath(this.options.trait)}.value`);
    }
    return submitData;
  }

  /* -------------------------------------------- */

  /**
   * Filter and order list of traits before submission.
   * @param {object} submitData  Form submission data.
   * @param {string} keyPath     Path to the trait to modify.
   * @protected
   */
  _filterData(submitData, keyPath) {
    foundry.utils.setProperty(submitData, keyPath, filteredKeys(
      foundry.utils.getProperty(submitData, keyPath) ?? {}
    ).sort((a, b) => a.localeCompare(b, "en")));
  }
}

/**
 * Configuration application for actor's damage resistances, immunities, and vulnerabilities.
 */
class DamagesConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["damages-config"]
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/damages-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get otherLabel() {
    return game.i18n.localize("DND5E.DamageTypes");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.bypasses = new SelectChoices(Object.entries(CONFIG.DND5E.itemProperties).reduce((obj, [k, v]) => {
      if ( v.isPhysical ) obj[k] = {
        label: v.label,
        chosen: context.data.bypasses.includes(k),
        icon: { src: k, svg: false }
      };
      return obj;
    }, {}));
    context.value = {};
    if ( this.options.trait === "dm" ) {
      context.choices.forEach((key, data) => data.chosen = context.data.amount[key] ?? "");
      context.bypassHint = "DND5E.DamageModification.BypassHint";
      context.hint = "DND5E.DamageModification.Hint";
      context.value.field = new FormulaField({ determinstic: true });
      context.value.key = "amount";
    } else {
      context.bypassHint = "DND5E.DamagePhysicalBypassHint";
      context.value.field = context.checkbox;
      context.value.input = context.inputs.createCheckboxInput;
      context.value.key = "value";
    }
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    const config = CONFIG.DND5E.damageTypes[key];
    if ( config ) choice.icon = { src: config.icon, svg: config.icon.endsWith(".svg") };
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    if ( this.options.trait === "dm" ) {
      for ( const [type, formula] of Object.entries(submitData.system?.traits?.dm?.amount ?? {}) ) {
        if ( !formula ) {
          delete submitData.system.traits.dm.amount[type];
          submitData.system.traits.dm.amount[`-=${type}`] = null;
        }
      }
    }
    this._filterData(submitData, `${actorKeyPath(this.options.trait)}.bypasses`);
    return submitData;
  }
}

class HabitatConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["habitat-config"],
    position: {
      width: 420
    }
  };

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/habitat-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Habitat.Configuration.Title");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const config = CONFIG.DND5E.habitats;
    const { details } = this.document.system._source;
    const value = Object.fromEntries(details.habitat.value.map(({ type, subtype }) => [type, { type, subtype }]));
    const any = "any" in value;
    context.custom = {
      field: this.document.system.schema.fields.details.fields.habitat.fields.custom,
      value: details.habitat.custom,
      name: "custom"
    };
    context.habitats = [
      { label: config.any.label, id: "any", checked: any },
      ...Object.entries(config).reduce((arr, [key, { label, subtypes }]) => {
        if ( key === "any" ) return arr;
        const checked = any || (key in value);
        arr.push({
          checked, label,
          id: key,
          disabled: any,
          subtype: value[key]?.subtype,
          subtypes: !any && subtypes && checked
        });
        return arr;
      }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
    ];
    context.rows = Math.ceil(context.habitats.reduce((n, { subtypes }) => n + (subtypes ? 2 : 1), 0) / 2);
    return context;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const value = Object.entries(submitData).reduce((arr, [id, data]) => {
      if ( id === "custom" ) return arr;
      const entry = { type: id };
      if ( data.subtype ) entry.subtype = data.subtype;
      if ( data.type ) arr.push(entry);
      return arr;
    }, []);
    return foundry.utils.expandObject({
      "system.details.habitat": { value, custom: submitData.custom }
    });
  }
}

/**
 * Configuration application for adjusting hit dice amounts and rolling.
 */
class HitDiceConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["hit-dice"],
    actions: {
      decrease: HitDiceConfig.#stepValue,
      increase: HitDiceConfig.#stepValue,
      roll: HitDiceConfig.#rollDie
    },
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/hit-dice-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.HitDice");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.classes = Array.from(this.document.system.attributes?.hd?.classes ?? []).map(cls => ({
      data: { ...cls.system.hd },
      denomination: Number(cls.system.hd.denomination.slice(1)),
      id: cls.id,
      label: `${cls.name} (${cls.system.hd.denomination})`
    })).sort((lhs, rhs) => rhs.denomination - lhs.denomination);
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle rolling a specific hit die.
   * @this {HitDiceConfig}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   */
  static async #rollDie(event, target) {
    await this.document.rollHitDie({ denomination: target.dataset.denomination });
    this.render();
  }

  /* -------------------------------------------- */

  /**
   * Handle stepping a hit die count up or down.
   * @this {HitDiceConfig}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   */
  static #stepValue(event, target) {
    const valueField = target.closest(".form-group").querySelector("input");
    if ( target.dataset.action === "increase" ) valueField?.stepUp();
    else valueField?.stepDown();
    this.submit();
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    if ( form.reportValidity() ) {
      const submitData = super._processFormData(event, form, formData);
      const classUpdates = Object.entries(submitData).map(([_id, value]) => ({
        _id, "system.hd.spent": this.document.items.get(_id).system.levels - value
      }));
      this.document.updateEmbeddedDocuments("Item", classUpdates);
    }
    return {};
  }
}

/**
 * Configuration application for hit point bonuses and current values.
 */
class HitPointsConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["hit-points"],
    actions: {
      roll: HitPointsConfig.#rollFormula
    },
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/hit-points-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.HitPoints");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.data = this.document.system.attributes.hp;
    context.fields = this.document.system.schema.fields.attributes.fields.hp.fields;
    context.source = this.document.system._source.attributes.hp;

    // Display positive ability modifier as its own row, but if negative merge into classes totals
    const ability = CONFIG.DND5E.abilities[CONFIG.DND5E.defaultAbilities.hitPoints];
    const mod = this.document.system.abilities?.[CONFIG.DND5E.defaultAbilities.hitPoints]?.mod ?? 0;
    if ( ability && (mod > 0) ) context.ability = { mod, name: ability.label };

    // Summarize HP from classes
    context.classes = Object.values(this.document.classes).map(cls => ({
      id: cls.id,
      anchor: cls.toAnchor().outerHTML,
      name: cls.name,
      total: cls.advancement.byType.HitPoints?.[0]?.[mod > 0 ? "total" : "getAdjustedTotal"](mod) ?? 0
    })).sort((lhs, rhs) => rhs.name - lhs.name);

    // Display active effects targeting bonus fields
    context.effects = {
      bonuses: this.document._prepareActiveEffectAttributions("system.attributes.hp.bonuses.level"),
      max: this.document._prepareActiveEffectAttributions("system.attributes.hp.max"),
      overall: this.document._prepareActiveEffectAttributions("system.attributes.hp.bonuses.overall")
    };
    for ( const [key, value] of Object.entries(context.effects) ) {
      context.effects[key] = value
        .filter(e => e.mode === CONST.ACTIVE_EFFECT_MODES.ADD)
        .map(e => ({ ...e, anchor: e.document.toAnchor().outerHTML}));
    }

    context.levels = this.document.system.details?.level ?? 0;
    context.levelMultiplier = `
      <span class="multiplier"><span class="times">&times;</span> ${formatNumber(context.levels)}</span>
    `;
    context.showCalculation = context.classes.length || context.fields.bonuses;
    context.showMaxInCalculation = context.showCalculation && (this.document.type === "npc");
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle rolling NPC health values using the provided formula.
   * @this {HitPointsConfig}
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The button that was clicked.
   * @protected
   */
  static async #rollFormula(event, target) {
    try {
      const roll = await this.document.rollNPCHitPoints();
      this.submit({ updateData: { "system.attributes.hp.max": roll.total } });
    } catch(error) {
      ui.notifications.error("DND5E.HPFormulaError", { localize: true });
      throw error;
    }
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processSubmitData(event, form, submitData) {
    const clone = this.document.clone(foundry.utils.deepClone(submitData));
    const { value, max } = this.document.system.attributes.hp;
    const maxDelta = clone.system.attributes.hp.max - max;
    const current = submitData.system.attributes.hp.value ?? value;
    foundry.utils.setProperty(submitData, "system.attributes.hp.value", Math.max(current + maxDelta, 0));
    super._processSubmitData(event, form, submitData);
  }
}

const { BooleanField: BooleanField$c } = foundry.data.fields;

/**
 * Configuration application for an actor's initiative.
 */
class InitiativeConfig extends BaseConfigSheet$1 {

  /** @override */
  static DEFAULT_OPTIONS = {
    position: {
      width: 420
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/initiative-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Initiative");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const source = this.document._source;

    const defaultAbility = CONFIG.DND5E.abilities[CONFIG.DND5E.defaultAbilities.initiative];
    context.abilityOptions = [
      { value: "", label: game.i18n.format("DND5E.DefaultSpecific", { default: defaultAbility.label.toLowerCase() }) },
      { rule: true },
      ...Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }))
    ];
    context.data = source.system.attributes.init;
    context.fields = this.document.system.schema.fields.attributes.fields.init.fields;

    const ability = this.document.system.attributes.init.ability || CONFIG.DND5E.defaultAbilities.initiative;
    const abilityConfig = CONFIG.DND5E.abilities[ability];
    context.ability = {
      label: game.i18n.format("DND5E.AbilityCheckConfigure", { ability: abilityConfig.label }),
      global: {
        field: this.document.system.schema.fields.bonuses.fields.abilities.fields.check,
        name: "system.bonuses.abilities.check",
        value: source.system.bonuses.abilities.check
      },
      local: {
        field: this.document.system.schema.fields.abilities.model.fields.bonuses.fields.check,
        name: `system.abilities.${ability}.bonuses.check`,
        value: source.system.abilities[ability]?.bonuses.check
      }
    };

    context.flags = {
      alert: {
        field: new BooleanField$c({ label: game.i18n.localize("DND5E.FlagsAlert") }),
        name: "flags.dnd5e.initiativeAlert",
        value: source.flags.dnd5e?.initiativeAlert
      },
      advantage: {
        field: new BooleanField$c({ label: game.i18n.localize("DND5E.FlagsInitiativeAdv") }),
        name: "flags.dnd5e.initiativeAdv",
        value: source.flags.dnd5e?.initiativeAdv
      }
    };

    return context;
  }
}

/**
 * Configuration application for languages.
 */
class LanguagesConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["languages"],
    trait: "languages"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/languages-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Languages");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);

    const unitOptions = Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => ({ value, label }));
    context.communication = Object.entries(CONFIG.DND5E.communicationTypes).map(([key, { label }]) => ({
      label, unitOptions,
      data: context.data.communication[key],
      fields: context.fields.communication.model.fields,
      keyPath: `${context.keyPath}.communication.${key}`
    }));

    return context;
  }
}

/**
 * Configuration application for an actor's skills & tools.
 */
class SkillToolConfig extends BaseProficiencyConfig {

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/skill-tool-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Configuration data for the ability being edited.
   * @type {SkillConfiguration|ToolConfiguration}
   */
  get propertyConfig() {
    return CONFIG.DND5E[this.options.trait === "skills" ? "skills" : "tools"][this.options.key];
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.abilityOptions = Object.entries(CONFIG.DND5E.abilities).map(([value, { label }]) => ({ value, label }));
    context.proficiencyOptions = Object.entries(CONFIG.DND5E.proficiencyLevels)
      .map(([value, label]) => ({ value, label }));
    context.section = `DND5E.${this.options.trait === "skills" ? "SKILL" : "TOOL"}.SECTIONS.`;
    context.global.skill = this.options.trait === "skills";
    return context;
  }
}

/**
 * Configuration application for actor's skills.
 */
class SkillsConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["skills"],
    trait: "skills",
    actions: {
      configure: SkillsConfig.#configureSkill
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/skills-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    context.skills = context.choices.OTHER.children;
    context.rows = Math.ceil(Object.keys(context.skills).length / 2);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    const skill = data[key];
    if ( skill ) {
      choice.value = skill.value;
      choice.total = this.document.system.skills[key]?.total;
      choice.tooltip = CONFIG.DND5E.proficiencyLevels[skill.value];
    }
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this.element.querySelectorAll("proficiency-cycle").forEach(e => {
      e.addEventListener("change", event => this.submit());
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Open the configuration sheet for a skill.
   * @this {SkillsConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #configureSkill(event, target) {
    const { key } = target.closest("[data-key]").dataset;
    new SkillToolConfig({ document: this.document, trait: "skills", key }).render(true);
  }
}

/**
 * Configuration application for overriding actor spell slots.
 */
class SpellSlotsConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    position: {
      width: 450
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/spell-slots-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.SpellSlotsConfig");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);

    const source = this.document._source.system.spells;
    const { spells } = this.document.system;
    context.overrides = Array.fromRange(Object.keys(CONFIG.DND5E.spellLevels).length - 1, 1).map(level => ({
      value: source[`spell${level}`]?.override,
      label: CONFIG.DND5E.spellLevels[level],
      name: `system.spells.spell${level}.override`,
      placeholder: spells[`spell${level}`]?.max ?? 0
    }));

    for ( const k of Object.keys(CONFIG.DND5E.spellcastingTypes) ) {
      const hasSpell = this.document.items.some(i => i.type === "spell" && i.system.preparation.mode === k);
      if ( parseInt(spells[k]?.level) || hasSpell ) context.overrides.push({
        label: CONFIG.DND5E.spellPreparationModes[k].label,
        value: source[k]?.override,
        name: `system.spells.${k}.override`,
        placeholder: spells[k]?.max ?? 0
      });
    }

    return context;
  }
}

/**
 * Configuration application for actor's tools.
 */
class ToolsConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["trait-columns", "tools"],
    trait: "tool",
    actions: {
      configure: ToolsConfig.#configureTool
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/tools-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    const tool = data[key];
    if ( tool ) {
      choice.hasEntry = true;
      choice.value = tool.value;
      choice.total = this.document.system.tools[key]?.total;
    }
    choice.tooltip = CONFIG.DND5E.proficiencyLevels[tool?.value ?? 0];
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onRender(context, options) {
    super._onRender(context, options);
    this.element.querySelectorAll("proficiency-cycle").forEach(e => {
      e.addEventListener("change", event => this.submit());
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Open the configuration sheet for a tool.
   * @this {ToolsConfig}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #configureTool(event, target) {
    const { key } = target.closest("[data-key]").dataset;
    new SkillToolConfig({ document: this.document, trait: this.options.trait, key }).render(true);
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = Object.entries(formData.object).reduce((obj, [k, value]) => {
      const key = k.split(".")[2];
      const tool = this.document.system._source.tools[key];
      const config = CONFIG.DND5E.tools[key];
      if ( tool && !value ) obj[`system.tools.-=${key}`] = null;
      else if ( !tool && value ) obj[`system.tools.${key}`] = { value, ability: config?.ability || "int" };
      else if ( value ) obj[`system.tools.${key}.value`] = value;
      return obj;
    }, {});
    return foundry.utils.expandObject(submitData);
  }
}

/**
 * Configuration application for an NPC's treasure categories.
 */
class TreasureConfig extends BaseConfigSheet$1 {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["treasure-config"],
    position: {
      width: 400
    }
  };

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/actors/config/treasure-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.Treasure.Configuration.Title");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const config = CONFIG.DND5E.treasure;
    const { details } = this.document.system._source;
    const any = details.treasure.value.includes("any");
    context.treasure = [
      { label: config.any.label, name: "any", checked: any },
      ...Object.entries(config).reduce((arr, [key, { label }]) => {
        if ( key === "any" ) return arr;
        arr.push({ label, name: key, checked: any || details.treasure.value.includes(key), disabled: any });
        return arr;
      }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang))
    ];
    return context;
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @override */
  _processFormData(event, form, formData) {
    return foundry.utils.expandObject({
      "system.details.treasure.value": Object.entries(formData.object).filter(([, v]) => v).map(([k]) => k)
    });
  }
}

/**
 * Configuration application for weapon proficiencies and masteries.
 */
class WeaponsConfig extends TraitsConfig {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["weapons"],
    trait: "weapon"
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    traits: {
      template: "systems/dnd5e/templates/actors/config/weapons-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("TYPES.Item.weaponPl");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processChoice(data, key, choice, categoryChosen=false) {
    super._processChoice(data, key, choice, categoryChosen);
    choice.mastery = {
      chosen: data.mastery.value?.includes(key),
      disabled: !choice.chosen
    };
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    this._filterData(submitData, `${actorKeyPath(this.options.trait)}.mastery.value`);
    return submitData;
  }
}

/**
 * @import { DropEffectValue } from "../../drag-drop.mjs"
 */

/**
 * Extend the basic ActorSheet class to suppose system-specific logic and functionality.
 * @abstract
 */
class ActorSheet5e extends ActorSheetMixin(ActorSheet) {

  /**
   * @typedef {object} FilterState5e
   * @property {string} name             Filtering by name.
   * @property {Set<string>} properties  Filtering by some property.
   */

  /**
   * Track the set of item filters which are applied
   * @type {Object<string, FilterState5e>}
   * @protected
   */
  _filters = {
    inventory: { name: "", properties: new Set() },
    spellbook: { name: "", properties: new Set() },
    features: { name: "", properties: new Set() },
    effects: { name: "", properties: new Set() }
  };

  /* -------------------------------------------- */

  /**
   * IDs for items on the sheet that have been expanded.
   * @type {Set<string>}
   * @protected
   */
  _expanded = new Set();

  /* -------------------------------------------- */

  /** @override */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      scrollY: [
        "dnd5e-inventory .inventory-list",
        "dnd5e-effects .effects-list",
        ".center-pane"
      ],
      tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}],
      width: 720,
      height: Math.max(680, Math.max(
        237 + (Object.keys(CONFIG.DND5E.abilities).length * 70),
        240 + (Object.keys(CONFIG.DND5E.skills).length * 24)
      )),
      elements: {
        effects: "dnd5e-effects",
        inventory: "dnd5e-inventory"
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * A set of item types that should be prevented from being dropped on this type of actor sheet.
   * @type {Set<string>}
   */
  static unsupportedItemTypes = new Set();

  /* -------------------------------------------- */

  /** @override */
  get template() {
    if ( !game.user.isGM && this.actor.limited ) return "systems/dnd5e/templates/actors/limited-sheet.hbs";
    return `systems/dnd5e/templates/actors/${this.actor.type}-sheet.hbs`;
  }

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @override */
  async getData(options) {

    // The Actor's data
    const source = this.actor.toObject();

    // Basic data
    const context = {
      actor: this.actor,
      source: source.system,
      system: this.actor.system,
      items: Array.from(this.actor.items),
      itemContext: {},
      abilities: foundry.utils.deepClone(this.actor.system.abilities),
      skills: foundry.utils.deepClone(this.actor.system.skills ?? {}),
      tools: foundry.utils.deepClone(this.actor.system.tools ?? {}),
      labels: this._getLabels(),
      movement: this._getMovementSpeed(this.actor.system),
      senses: this._getSenses(this.actor.system),
      effects: EffectsElement.prepareCategories(this.actor.allApplicableEffects()),
      warnings: foundry.utils.deepClone(this.actor._preparationWarnings),
      filters: this._filters,
      owner: this.actor.isOwner,
      limited: this.actor.limited,
      options: this.options,
      editable: this.isEditable,
      cssClass: this.actor.isOwner ? "editable" : "locked",
      isCharacter: this.actor.type === "character",
      isNPC: this.actor.type === "npc",
      isVehicle: this.actor.type === "vehicle",
      config: CONFIG.DND5E,
      rollableClass: this.isEditable ? "rollable" : "",
      rollData: this.actor.getRollData(),
      overrides: {
        attunement: foundry.utils.hasProperty(this.actor.overrides, "system.attributes.attunement.max")
      },
      elements: this.options.elements
    };

    // Remove items in containers & sort remaining
    context.items = context.items
      .filter(i => !this.actor.items.has(i.system.container))
      .sort((a, b) => (a.sort || 0) - (b.sort || 0));

    // Temporary HP
    const hp = {...context.system.attributes.hp};
    if ( hp.temp === 0 ) delete hp.temp;
    if ( hp.tempmax === 0 ) delete hp.tempmax;
    context.hp = hp;

    // Ability Scores
    for ( const [a, abl] of Object.entries(context.abilities) ) {
      abl.icon = this._getProficiencyIcon(abl.proficient);
      abl.hover = CONFIG.DND5E.proficiencyLevels[abl.proficient];
      abl.label = CONFIG.DND5E.abilities[a]?.label;
      abl.baseProf = source.system.abilities[a]?.proficient ?? 0;
      abl.key = a;
    }

    // Skills & tools.
    const baseAbility = (prop, key) => {
      let src = source.system[prop]?.[key]?.ability;
      if ( src ) return src;
      if ( prop === "skills" ) src = CONFIG.DND5E.skills[key]?.ability;
      return src ?? "int";
    };
    ["skills", "tools"].forEach(prop => {
      for ( const [key, entry] of Object.entries(context[prop]) ) {
        entry.abbreviation = CONFIG.DND5E.abilities[entry.ability]?.abbreviation;
        entry.icon = this._getProficiencyIcon(entry.value);
        entry.hover = CONFIG.DND5E.proficiencyLevels[entry.value];
        entry.label = (prop === "skills") ? CONFIG.DND5E.skills[key]?.label : keyLabel(key, {trait: "tool"});
        entry.baseValue = source.system[prop]?.[key]?.value ?? 0;
        entry.baseAbility = baseAbility(prop, key);
      }
    });

    // Update traits
    context.traits = this._prepareTraits(context.system);

    // Prepare owned items
    this._prepareItems(context);
    context.expandedData = {};
    for ( const id of this._expanded ) {
      const item = this.actor.items.get(id);
      if ( item ) {
        context.expandedData[id] = await item.getChatData({secrets: this.actor.isOwner});
        if ( context.itemContext[id] ) context.itemContext[id].expanded = context.expandedData[id];
      }
    }

    // Biography HTML enrichment
    context.biographyHTML = await TextEditor.enrichHTML(context.system.details.biography.value, {
      secrets: this.actor.isOwner,
      rollData: context.rollData,
      relativeTo: this.actor
    });

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare labels object for the context.
   * @returns {object}           Object containing various labels.
   * @protected
   */
  _getLabels() {
    const labels = {...this.actor.labels};

    // Currency Labels
    labels.currencies = Object.entries(CONFIG.DND5E.currencies).reduce((obj, [k, c]) => {
      obj[k] = c.label;
      return obj;
    }, {});

    // Proficiency
    labels.proficiency = game.settings.get("dnd5e", "proficiencyModifier") === "dice"
      ? `d${this.actor.system.attributes.prof * 2}`
      : `+${this.actor.system.attributes.prof}`;

    return labels;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the display of movement speed data for the Actor.
   * @param {object} systemData               System data for the Actor being prepared.
   * @param {boolean} [largestPrimary=false]  Show the largest movement speed as "primary", otherwise show "walk".
   * @returns {{primary: string, special: string}}
   * @protected
   */
  _getMovementSpeed(systemData, largestPrimary=false) {
    const movement = systemData.attributes.movement ?? {};

    // Prepare an array of available movement speeds
    let speeds = [
      [movement.burrow, `${game.i18n.localize("DND5E.MovementBurrow")} ${movement.burrow}`],
      [movement.climb, `${game.i18n.localize("DND5E.MovementClimb")} ${movement.climb}`],
      [movement.fly, `${game.i18n.localize("DND5E.MovementFly")} ${movement.fly}${movement.hover ? ` (${game.i18n.localize("DND5E.MovementHover")})` : ""}`],
      [movement.swim, `${game.i18n.localize("DND5E.MovementSwim")} ${movement.swim}`]
    ];
    if ( largestPrimary ) {
      speeds.push([movement.walk, `${game.i18n.localize("DND5E.MovementWalk")} ${movement.walk}`]);
    }

    // Filter and sort speeds on their values
    speeds = speeds.filter(s => s[0]).sort((a, b) => b[0] - a[0]);
    const units = movement.units ?? defaultUnits("length");

    // Case 1: Largest as primary
    if ( largestPrimary ) {
      let primary = speeds.shift();
      return {
        primary: `${primary?.[1]} ${units}`,
        special: speeds.map(s => s[1]).join(", ")
      };
    }

    // Case 2: Walk as primary
    else {
      return {
        primary: formatLength(movement.walk ?? 0, units),
        special: speeds.length ? speeds.map(s => s[1]).join(", ") : ""
      };
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare senses object for display.
   * @param {object} systemData  System data for the Actor being prepared.
   * @returns {object}           Senses grouped by key with localized and formatted string.
   * @protected
   */
  _getSenses(systemData) {
    const senses = systemData.attributes.senses ?? {};
    const tags = {};
    const units = senses.units ?? defaultUnits("length");
    for ( let [k, label] of Object.entries(CONFIG.DND5E.senses) ) {
      const v = senses[k] ?? 0;
      if ( v === 0 ) continue;
      tags[k] = `${game.i18n.localize(label)} ${formatLength(v, units)}`;
    }
    if ( senses.special ) splitSemicolons(senses.special).forEach((c, i) => tags[`custom${i + 1}`] = c);
    return tags;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async activateEditor(name, options={}, initialContent="") {
    options.relativeLinks = true;
    return super.activateEditor(name, options, initialContent);
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data structure for traits data like languages, resistances & vulnerabilities, and proficiencies.
   * @param {object} systemData  System data for the Actor being prepared.
   * @returns {object}           Prepared trait data.
   * @protected
   */
  _prepareTraits(systemData) {
    const traits = {};
    for ( const [trait$1, traitConfig] of Object.entries(CONFIG.DND5E.traits) ) {
      if ( trait$1 === "dm" ) continue;
      const key = traitConfig.actorKeyPath?.replace("system.", "") ?? `traits.${trait$1}`;
      const data = foundry.utils.deepClone(foundry.utils.getProperty(systemData, key));
      if ( !data ) continue;
      foundry.utils.setProperty(traits, key, data);
      let values = data.value;
      if ( !values ) values = [];
      else if ( values instanceof Set ) values = Array.from(values);
      else if ( !Array.isArray(values) ) values = [values];

      // Split physical damage types from others if bypasses is set
      const physical = [];
      if ( data.bypasses?.size ) {
        values = values.filter(t => {
          if ( !CONFIG.DND5E.damageTypes[t]?.isPhysical ) return true;
          physical.push(t);
          return false;
        });
      }

      data.selected = values.reduce((obj, key) => {
        obj[key] = keyLabel(key, { trait: trait$1 }) ?? key;
        return obj;
      }, {});

      // Display bypassed damage types
      if ( physical.length ) {
        const damageTypesFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "conjunction" });
        const bypassFormatter = new Intl.ListFormat(game.i18n.lang, { style: "long", type: "disjunction" });
        data.selected.physical = game.i18n.format("DND5E.DamagePhysicalBypasses", {
          damageTypes: damageTypesFormatter.format(physical.map(t => keyLabel(t, { trait: trait$1 }))),
          bypassTypes: bypassFormatter.format(data.bypasses.reduce((acc, t) => {
            const v = CONFIG.DND5E.itemProperties[t];
            if ( v && v.isPhysical ) acc.push(v.label);
            return acc;
          }, []))
        });
      }

      // Add custom entries
      if ( data.custom ) splitSemicolons(data.custom).forEach((c, i) => data.selected[`custom${i + 1}`] = c);
      data.cssClass = !foundry.utils.isEmpty(data.selected) ? "" : "inactive";

      // If petrified, display "All Damage" instead of all damage types separately
      if ( (trait$1 === "dr") && this.document.hasConditionEffect("petrification") ) {
        data.selected = { custom1: game.i18n.localize("DND5E.DamageAll") };
        data.cssClass = "";
      }
    }
    return traits;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the data structure for items which appear on the actor sheet.
   * Each subclass overrides this method to implement type-specific logic.
   * @protected
   */
  _prepareItems() {}

  /* -------------------------------------------- */

  /**
   * Insert a spell into the spellbook object when rendering the character sheet.
   * @param {object} context    Sheet rendering context data being prepared for render.
   * @param {object[]} spells   Spells to be included in the spellbook.
   * @returns {object[]}        Spellbook sections in the proper order.
   * @protected
   */
  _prepareSpellbook(context, spells) {
    const owner = this.actor.isOwner;
    const levels = context.actor.system.spells;
    const spellbook = {};

    // Define section and label mappings
    const sections = Object.entries(CONFIG.DND5E.spellPreparationModes).reduce((acc, [k, {order}]) => {
      if ( Number.isNumeric(order) ) acc[k] = Number(order);
      return acc;
    }, {});
    const useLabels = {"-30": "-", "-20": "-", "-10": "-", 0: "&infin;"};

    // Format a spellbook entry for a certain indexed level
    const registerSection = (sl, i, label, {prepMode="prepared", value, max, override, config}={}) => {
      const aeOverride = foundry.utils.hasProperty(this.actor.overrides, `system.spells.spell${i}.override`);
      spellbook[i] = {
        order: i,
        label: label,
        usesSlots: i > 0,
        canCreate: owner,
        canPrepare: ((context.actor.type === "character") && (i >= 1)) || config?.prepares,
        spells: [],
        uses: useLabels[i] || value || 0,
        slots: useLabels[i] || max || 0,
        override: override || 0,
        dataset: {type: "spell", level: prepMode in sections ? 1 : i, preparationMode: prepMode},
        prop: sl,
        editable: context.editable && !aeOverride
      };
    };

    // Determine the maximum spell level which has a slot
    const maxLevel = Array.fromRange(Object.keys(CONFIG.DND5E.spellLevels).length - 1, 1).reduce((max, i) => {
      const level = levels[`spell${i}`];
      if ( level && (level.max || level.override ) && ( i > max ) ) max = i;
      return max;
    }, 0);

    // Level-based spellcasters have cantrips and leveled slots
    if ( maxLevel > 0 ) {
      registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]);
      for (let lvl = 1; lvl <= maxLevel; lvl++) {
        const sl = `spell${lvl}`;
        registerSection(sl, lvl, CONFIG.DND5E.spellLevels[lvl], levels[sl]);
      }
    }

    // Create spellbook sections for all alternative spell preparation modes that have spell slots.
    for ( const [k, v] of Object.entries(CONFIG.DND5E.spellPreparationModes) ) {
      if ( !(k in levels) || !v.upcast || !levels[k].max ) continue;

      if ( !spellbook["0"] && v.cantrips ) registerSection("spell0", 0, CONFIG.DND5E.spellLevels[0]);
      const l = levels[k];
      const level = game.i18n.localize(`DND5E.SpellLevel${l.level}`);
      const label = `${v.label} — ${level}`;
      registerSection(k, sections[k], label, {
        prepMode: k,
        value: l.value,
        max: l.max,
        override: l.override,
        config: v
      });
    }

    // Iterate over every spell item, adding spells to the spellbook by section
    spells.forEach(spell => {
      const mode = spell.system.preparation.mode || "prepared";
      let s = spell.system.level || 0;
      const sl = `spell${s}`;

      // Spells from items
      if ( spell.getFlag("dnd5e", "cachedFor") ) {
        s = "item";
        if ( !spell.system.linkedActivity?.displayInSpellbook ) return;
        if ( !spellbook[s] ) {
          registerSection(null, s, game.i18n.localize("DND5E.CAST.SECTIONS.Spellbook"));
          spellbook[s].order = 1000;
        }
      }

      // Specialized spellcasting modes (if they exist)
      else if ( mode in sections ) {
        s = sections[mode];
        if ( !spellbook[s] ) {
          const l = levels[mode] || {};
          const config = CONFIG.DND5E.spellPreparationModes[mode];
          registerSection(mode, s, config.label, {
            prepMode: mode,
            value: l.value,
            max: l.max,
            override: l.override,
            config: config
          });
        }
      }

      // Sections for higher-level spells which the caster "should not" have, but spell items exist for
      else if ( !spellbook[s] ) {
        registerSection(sl, s, CONFIG.DND5E.spellLevels[s], {levels: levels[sl]});
      }

      // Add the spell to the relevant heading
      spellbook[s].spells.push(spell);
    });

    // Sort the spellbook by section level
    const sorted = Object.values(spellbook);
    sorted.sort((a, b) => a.order - b.order);
    return sorted;
  }

  /* -------------------------------------------- */

  /**
   * Filter child embedded Documents based on the current set of filters.
   * @param {string} collection    The embedded collection name.
   * @param {Set<string>} filters  Filters to apply to the children.
   * @returns {Document[]}
   * @protected
   */
  _filterChildren(collection, filters) {
    switch ( collection ) {
      case "items": return this._filterItems(this.actor.items, filters);
      case "effects": return this._filterEffects(Array.from(this.actor.allApplicableEffects()), filters);
    }
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Filter Active Effects based on the current set of filters.
   * @param {ActiveEffect5e[]} effects  The effects to filter.
   * @param {Set<string>} filters       Filters to apply to the effects.
   * @returns {ActiveEffect5e[]}
   * @protected
   */
  _filterEffects(effects, filters) {
    return effects;
  }

  /* -------------------------------------------- */

  /**
   * Filter items based on the current set of filters.
   * @param {Item5e[]} items       Copies of item data to be filtered.
   * @param {Set<string>} filters  Filters applied to the item list.
   * @returns {Item5e[]}           Subset of input items limited by the provided filters.
   * @protected
   */
  _filterItems(items, filters) {
    const alwaysPrepared = ["innate", "always"];
    const actions = ["action", "bonus", "reaction"];
    const recoveries = ["lr", "sr"];
    const spellSchools = new Set(Object.keys(CONFIG.DND5E.spellSchools));
    const schoolFilter = spellSchools.intersection(filters);
    const spellcastingClasses = new Set(Object.keys(this.actor.spellcastingClasses));
    const classFilter = spellcastingClasses.intersection(filters);

    return items.filter(item => {

      // Subclass-specific logic.
      const filtered = this._filterItem(item, filters);
      if ( filtered !== undefined ) return filtered;

      // Action usage
      for ( const f of actions ) {
        if ( !filters.has(f) ) continue;
        if ( item.type === "spell" ) {
          if ( item.system.activation.type !== f ) return false;
          continue;
        }
        if ( !item.system.activities?.size ) return false;
        if ( item.system.activities.every(a => a.activation.type !== f) ) return false;
      }

      // Spell-specific filters
      if ( filters.has("ritual") && !item.system.properties?.has("ritual") ) return false;
      if ( filters.has("concentration") && !item.system.properties?.has("concentration") ) return false;
      if ( schoolFilter.size && !schoolFilter.has(item.system.school) ) return false;
      if ( classFilter.size && !classFilter.has(item.system.sourceClass) ) return false;
      if ( filters.has("prepared") ) {
        if ( alwaysPrepared.includes(item.system.preparation?.mode) ) return true;
        return item.system.preparation?.prepared;
      }

      // Equipment-specific filters
      if ( filters.has("equipped") && (item.system.equipped !== true) ) return false;
      if ( filters.has("mgc") && !item.system.properties?.has("mgc") ) return false;

      // Recovery
      for ( const f of recoveries ) {
        if ( !filters.has(f) ) continue;
        if ( !item.system.uses?.recovery.length ) return false;
        if ( item.system.uses.recovery.every(r => r.period !== f) ) return false;
      }

      return true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Determine whether an Item will be shown based on the current set of filters.
   * @param {Item5e} item          The item.
   * @param {Set<string>} filters  Filters applied to the Item.
   * @returns {boolean|void}
   * @protected
   */
  _filterItem(item, filters) {}

  /* -------------------------------------------- */

  /**
   * Get the font-awesome icon used to display a certain level of skill proficiency.
   * @param {number} level  A proficiency mode defined in `CONFIG.DND5E.proficiencyLevels`.
   * @returns {string}      HTML string for the chosen icon.
   * @private
   */
  _getProficiencyIcon(level) {
    const icons = {
      0: '<i class="far fa-circle"></i>',
      0.5: '<i class="fas fa-adjust"></i>',
      1: '<i class="fas fa-check"></i>',
      2: '<i class="fas fa-check-double"></i>'
    };
    return icons[level] || icons[0];
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    // Property attributions
    this.form.querySelectorAll("[data-attribution], .attributable").forEach(this._applyAttributionTooltips.bind(this));

    // Preparation Warnings
    html.find(".warnings").click(this._onWarningLink.bind(this));

    // Editable Only Listeners
    if ( this.isEditable ) {
      // Input focus and update
      const inputs = html.find("input");
      inputs.focus(ev => ev.currentTarget.select());
      inputs.addBack().find('[type="text"][data-dtype="Number"]').change(this._onChangeInputDelta.bind(this));

      // Ability Proficiency
      html.find(".ability-proficiency").click(this._onToggleAbilityProficiency.bind(this));

      // Toggle Skill Proficiency
      html.find(".skill-proficiency").on("click contextmenu", event => this._onCycleProficiency(event, "skill"));

      // Toggle Tool Proficiency
      html.find(".tool-proficiency").on("click contextmenu", event => this._onCycleProficiency(event, "tool"));

      // Trait Selector
      html.find(".trait-selector").click(this._onTraitSelector.bind(this));

      // Configure Special Flags
      html.find(".config-button").click(this._onConfigMenu.bind(this));

      // Changing Level
      html.find(".level-selector").change(this._onLevelChange.bind(this));

      // Owned Item management
      html.find(".slot-max-override").click(this._onSpellSlotOverride.bind(this));
      html.find(".attunement-max-override").click(this._onAttunementOverride.bind(this));

      this._disableOverriddenFields(html);
    }

    // Owner Only Listeners, for non-compendium actors.
    if ( this.actor.isOwner && !this.actor[game.release.generation < 13 ? "compendium" : "inCompendium"] ) {
      // Ability Checks
      html.find(".ability-name").click(this._onRollAbilityTest.bind(this));

      // Roll Skill Checks
      html.find(".skill-name").click(this._onRollSkillCheck.bind(this));

      // Roll Tool Checks.
      html.find(".tool-name").on("click", this._onRollToolCheck.bind(this));
    }

    // Handle default listeners last so system listeners are triggered first
    super.activateListeners(html);
  }

  /* -------------------------------------------- */

  /**
   * Disable any fields that are overridden by active effects and display an informative tooltip.
   * @param {jQuery} html  The sheet's rendered HTML.
   * @protected
   */
  _disableOverriddenFields(html) {
    const proficiencyToggles = {
      ability: /system\.abilities\.([^.]+)\.proficient/,
      skill: /system\.skills\.([^.]+)\.value/,
      tool: /system\.tools\.([^.]+)\.value/
    };

    for ( const override of Object.keys(foundry.utils.flattenObject(this.actor.overrides)) ) {
      html.find(`input[name="${override}"],select[name="${override}"]`).each((i, el) => {
        el.disabled = true;
        el.dataset.tooltip = "DND5E.ActiveEffectOverrideWarning";
      });

      for ( const [key, regex] of Object.entries(proficiencyToggles) ) {
        const [, match] = override.match(regex) || [];
        if ( match ) {
          const toggle = html.find(`li[data-${key}="${match}"] .proficiency-toggle`);
          toggle.addClass("disabled");
          toggle.attr("data-tooltip", "DND5E.ActiveEffectOverrideWarning");
        }
      }

      const [, spell] = override.match(/system\.spells\.(spell\d)\.override/) || [];
      if ( spell ) {
        html.find(`.spell-max[data-level="${spell}"]`).attr("data-tooltip", "DND5E.ActiveEffectOverrideWarning");
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Respond to a new level being selected from the level selector.
   * @param {Event} event                           The originating change.
   * @returns {Promise<AdvancementManager|Item5e>}  Manager if advancements needed, otherwise updated class item.
   * @private
   */
  async _onLevelChange(event) {
    event.preventDefault();
    const delta = Number(event.target.value);
    const classId = event.target.closest("[data-item-id]")?.dataset.itemId;
    if ( !delta || !classId ) return;
    const classItem = this.actor.items.get(classId);
    if ( !game.settings.get("dnd5e", "disableAdvancements") ) {
      const manager = AdvancementManager.forLevelChange(this.actor, classId, delta);
      if ( manager.steps.length ) {
        if ( delta > 0 ) return manager.render(true);
        try {
          const shouldRemoveAdvancements = await AdvancementConfirmationDialog.forLevelDown(classItem);
          if ( shouldRemoveAdvancements ) return manager.render(true);
        }
        catch(err) {
          return;
        }
      }
    }
    return classItem.update({"system.levels": classItem.system.levels + delta});
  }

  /* -------------------------------------------- */

  /**
   * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options.
   * @param {Event} event   The click event which originated the selection.
   * @protected
   */
  _onConfigMenu(event) {
    event.preventDefault();
    event.stopPropagation();
    const button = event.currentTarget;
    let app;
    switch ( button.dataset.action ) {
      case "armor":
        app = new ArmorClassConfig({ document: this.actor });
        break;
      case "death":
        app = new DeathConfig({ document: this.actor });
        break;
      case "habitat":
        app = new HabitatConfig({ document: this.actor });
        break;
      case "hitDice":
        app = new HitDiceConfig({ document: this.actor });
        break;
      case "hitPoints":
        app = new HitPointsConfig({ document: this.actor });
        break;
      case "initiative":
        app = new InitiativeConfig({ document: this.actor });
        break;
      case "movement":
      case "senses":
        app = new MovementSensesConfig({ document: this.actor, type: button.dataset.action });
        break;
      case "treasure":
        app = new TreasureConfig({ document: this.actor });
        break;
      case "flags":
        app = new ActorSheetFlags(this.actor);
        break;
      case "source":
        app = new SourceConfig({ document: this.actor });
        break;
      case "type":
        app = new CreatureTypeConfig({ document: this.actor });
        break;
      case "ability":
        const ability = event.currentTarget.closest("[data-ability]").dataset.ability;
        if ( ability === "concentration" ) app = new ConcentrationConfig({ document: this.actor });
        else app = new AbilityConfig({ document: this.actor, key: ability });
        break;
      case "skill":
        const skill = event.currentTarget.closest("[data-key]").dataset.key;
        app = new SkillToolConfig({ document: this.actor, trait: "skills", key: skill });
        break;
      case "skills":
        app = new SkillsConfig({ document: this.actor });
        break;
      case "spellSlots":
        app = new SpellSlotsConfig({ document: this.actor });
        break;
      case "tool":
        const tool = event.currentTarget.closest("[data-key]").dataset.key;
        app = new SkillToolConfig({ document: this.actor, trait: "tool", key: tool });
        break;
    }
    app?.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle cycling proficiency in a skill or tool.
   * @param {Event} event     A click or contextmenu event which triggered this action.
   * @returns {Promise|void}  Updated data for this actor after changes are applied.
   * @protected
   */
  _onCycleProficiency(event) {
    if ( event.currentTarget.classList.contains("disabled") ) return;
    event.preventDefault();
    const parent = event.currentTarget.closest(".proficiency-row");
    const field = parent.querySelector('[name$=".value"]');
    const {property, key} = parent.dataset;
    const value = this.actor._source.system[property]?.[key]?.value ?? 0;

    // Cycle to the next or previous skill level.
    const levels = [0, 1, .5, 2];
    const idx = levels.indexOf(value);
    const next = idx + (event.type === "contextmenu" ? 3 : 1);
    field.value = levels[next % levels.length];

    // Update the field value and save the form.
    return this._onSubmit(event);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    const li = event.currentTarget;
    if ( event.target.classList.contains("content-link") ) return;

    if ( li.dataset.effectId && li.dataset.parentId ) {
      const effect = this.actor.items.get(li.dataset.parentId)?.effects.get(li.dataset.effectId);
      if ( effect ) event.dataTransfer.setData("text/plain", JSON.stringify(effect.toDragData()));
      return;
    }

    super._onDragStart(event);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropActor(event, data) {
    const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get("dnd5e", "allowPolymorphing"));
    if ( !canPolymorph || (this._tabs[0].active === "bastion") ) return false;

    // Get the target actor
    const cls = getDocumentClass("Actor");
    const sourceActor = await cls.fromDropData(data);
    if ( !sourceActor ) return;

    // Define a function to record polymorph settings for future use
    const rememberOptions = html => {
      const options = {};
      html.find("input").each((i, el) => {
        options[el.name] = el.checked;
      });
      const settings = foundry.utils.mergeObject(game.settings.get("dnd5e", "polymorphSettings") ?? {}, options);
      game.settings.set("dnd5e", "polymorphSettings", settings);
      return settings;
    };

    // Create and render the Dialog
    return new Dialog({
      title: game.i18n.localize("DND5E.PolymorphPromptTitle"),
      content: {
        options: game.settings.get("dnd5e", "polymorphSettings"),
        settings: CONFIG.DND5E.polymorphSettings,
        effectSettings: CONFIG.DND5E.polymorphEffectSettings,
        isToken: this.actor.isToken
      },
      default: "accept",
      buttons: {
        accept: {
          icon: '<i class="fas fa-check"></i>',
          label: game.i18n.localize("DND5E.PolymorphAcceptSettings"),
          callback: html => this.actor.transformInto(sourceActor, rememberOptions(html))
        },
        wildshape: {
          icon: CONFIG.DND5E.transformationPresets.wildshape.icon,
          label: CONFIG.DND5E.transformationPresets.wildshape.label,
          callback: html => this.actor.transformInto(sourceActor, foundry.utils.mergeObject(
            CONFIG.DND5E.transformationPresets.wildshape.options,
            { transformTokens: rememberOptions(html).transformTokens }
          ))
        },
        polymorph: {
          icon: CONFIG.DND5E.transformationPresets.polymorph.icon,
          label: CONFIG.DND5E.transformationPresets.polymorph.label,
          callback: html => this.actor.transformInto(sourceActor, foundry.utils.mergeObject(
            CONFIG.DND5E.transformationPresets.polymorph.options,
            { transformTokens: rememberOptions(html).transformTokens }
          ))
        },
        self: {
          icon: CONFIG.DND5E.transformationPresets.polymorphSelf.icon,
          label: CONFIG.DND5E.transformationPresets.polymorphSelf.label,
          callback: html => this.actor.transformInto(sourceActor, foundry.utils.mergeObject(
            CONFIG.DND5E.transformationPresets.polymorphSelf.options,
            { transformTokens: rememberOptions(html).transformTokens }
          ))
        },
        cancel: {
          icon: '<i class="fas fa-times"></i>',
          label: game.i18n.localize("Cancel")
        }
      }
    }, {
      classes: ["dialog", "dnd5e", "polymorph"],
      width: 900,
      template: "systems/dnd5e/templates/apps/polymorph-prompt.hbs"
    }).render(true);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropActiveEffect(event, data) {
    const effect = await ActiveEffect.implementation.fromDropData(data);
    if ( effect?.target === this.actor ) return false;
    return super._onDropActiveEffect(event, data);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropItem(event, data) {
    const behavior = this._dropBehavior(event, data);
    if ( !this.actor.isOwner || (behavior === "none") ) return false;
    const item = await Item.implementation.fromDropData(data);

    // Handle moving out of container & item sorting
    if ( (behavior === "move") && (this.actor.uuid === item.parent?.uuid) ) {
      if ( item.system.container !== null ) await item.update({ "system.container": null });
      return this._onSortItem(event, item.toObject());
    }

    return this._onDropItemCreate(item, event, behavior);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropFolder(event, data) {
    if ( !this.actor.isOwner ) return [];
    const folder = await Folder.implementation.fromDropData(data);
    if ( folder.type !== "Item" ) return [];
    const droppedItemData = await Promise.all(folder.contents.map(async item => {
      if ( !(item instanceof Item) ) item = await fromUuid(item.uuid);
      return item;
    }));
    return this._onDropItemCreate(droppedItemData, event);
  }

  /* -------------------------------------------- */

  /**
   * Handle the final creation of dropped Item data on the Actor.
   * @param {Item5e[]|Item5e} itemData     The item or items requested for creation.
   * @param {DragEvent} event              The concluding DragEvent which provided the drop data.
   * @param {DropEffectValue} behavior     The specific drop behavior.
   * @returns {Promise<Item5e[]>}
   * @protected
   */
  async _onDropItemCreate(itemData, event, behavior) {
    let items = itemData instanceof Array ? itemData : [itemData];
    const itemsWithoutAdvancement = items.filter(i => !i.system.advancement?.length);
    const multipleAdvancements = (items.length - itemsWithoutAdvancement.length) > 1;
    if ( multipleAdvancements && !game.settings.get("dnd5e", "disableAdvancements") ) {
      ui.notifications.warn(game.i18n.format("DND5E.WarnCantAddMultipleAdvancements"));
      items = itemsWithoutAdvancement;
    }

    // Filter out items already in containers to avoid creating duplicates
    const containers = new Set(items.filter(i => i.type === "container").map(i => i._id));
    items = items.filter(i => !containers.has(i.system.container));

    // Create the owned items & contents as normal
    const toCreate = await Item5e.createWithContents(items, {
      transformFirst: item => {
        if ( item instanceof foundry.abstract.Document ) item = item.toObject();
        return this._onDropSingleItem(item, event);
      }
    });
    const created = await Item5e.createDocuments(toCreate, { pack: this.actor.pack, parent: this.actor, keepId: true });
    if ( behavior === "move" ) items.forEach(i => fromUuid(i.uuid).then(d => d?.delete({ deleteContents: true })));
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Handles dropping of a single item onto this character sheet.
   * @param {object} itemData            The item data to create.
   * @param {DragEvent} event            The concluding DragEvent which provided the drop data.
   * @returns {Promise<object|boolean>}  The item data to create after processing, or false if the item should not be
   *                                     created or creation has been otherwise handled.
   * @protected
   */
  async _onDropSingleItem(itemData, event) {
    // Check to make sure items of this type are allowed on this actor
    if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) {
      ui.notifications.warn(game.i18n.format("DND5E.ActorWarningInvalidItem", {
        itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
        actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type])
      }));
      return false;
    }

    // Create a Consumable spell scroll on the Inventory tab
    if ( (itemData.type === "spell")
      && (this._tabs[0].active === "inventory" || this.actor.type === "vehicle") ) {
      const scroll = await Item5e.createScrollFromSpell(itemData);
      return scroll?.toObject?.() ?? false;
    }

    // Clean up data
    this._onDropResetData(itemData, event);

    // Stack identical consumables
    const stacked = this._onDropStackConsumables(itemData);
    if ( stacked ) return false;

    // Bypass normal creation flow for any items with advancement
    if ( this.actor.system.metadata?.supportsAdvancement && itemData.system.advancement?.length
        && !game.settings.get("dnd5e", "disableAdvancements") ) {
      // Ensure that this item isn't violating the singleton rule
      const dataModel = CONFIG.Item.dataModels[itemData.type];
      const singleton = dataModel?.metadata.singleton ?? false;
      if ( singleton && this.actor.itemTypes[itemData.type].length ) {
        ui.notifications.error(game.i18n.format("DND5E.ActorWarningSingleton", {
          itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
          actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type])
        }));
        return false;
      }

      const manager = AdvancementManager.forNewItem(this.actor, itemData);
      if ( manager.steps.length ) {
        manager.render(true);
        return false;
      }
    }

    // Adjust the preparation mode of a leveled spell depending on the section on which it is dropped.
    if ( itemData.type === "spell" ) this._onDropSpell(itemData, event);

    return itemData;
  }

  /* -------------------------------------------- */

  /**
   * Reset certain pieces of data stored on items when they are dropped onto the actor.
   * @param {object} itemData    The item data requested for creation. **Will be mutated.**
   * @param {DragEvent} event    The concluding DragEvent which provided the drop data.
   */
  _onDropResetData(itemData, event) {
    if ( !itemData.system ) return;
    ["attuned", "equipped", "prepared"].forEach(k => delete itemData.system[k]);
  }

  /* -------------------------------------------- */

  /**
   * Adjust the preparation mode of a dropped spell depending on the drop location on the sheet.
   * @param {object} itemData    The item data requested for creation. **Will be mutated.**
   * @param {DragEvent} event    The concluding DragEvent which provided the drop data.
   */
  _onDropSpell(itemData, event) {
    if ( !["npc", "character"].includes(this.document.type) ) return;

    // Determine the section it is dropped on, if any.
    let header = event.target.closest(".items-header"); // Dropped directly on the header.
    if ( !header ) {
      const list = event.target.closest(".item-list"); // Dropped inside an existing list.
      header = list?.previousElementSibling;
    }
    const { level, preparationMode } = header?.closest("[data-level]")?.dataset ?? {};

    // Determine the actor's spell slot progressions, if any.
    const spellcastKeys = Object.keys(CONFIG.DND5E.spellcastingTypes);
    const progs = Object.values(this.document.classes).reduce((acc, cls) => {
      const type = cls.spellcasting?.type;
      if ( spellcastKeys.includes(type) ) acc.add(type);
      return acc;
    }, new Set());

    const prep = itemData.system.preparation;

    // Case 1: Drop a cantrip.
    if ( itemData.system.level === 0 ) {
      const modes = CONFIG.DND5E.spellPreparationModes;
      if ( modes[preparationMode]?.cantrips ) {
        prep.mode = "prepared";
      } else if ( !preparationMode ) {
        const isCaster = this.document.system.attributes.spell?.level || progs.size;
        prep.mode = isCaster ? "prepared" : "innate";
      } else {
        prep.mode = preparationMode;
      }
      if ( modes[prep.mode]?.prepares ) prep.prepared = true;
    }

    // Case 2: Drop a leveled spell in a section without a mode.
    else if ( (level === "0") || !preparationMode ) {
      if ( this.document.type === "npc" ) {
        prep.mode = this.document.system.attributes.spell.level ? "prepared" : "innate";
      } else {
        const m = progs.has("leveled") ? "prepared" : (progs.first() ?? "innate");
        prep.mode = progs.has(prep.mode) ? prep.mode : m;
      }
    }

    // Case 3: Drop a leveled spell in a specific section.
    else prep.mode = preparationMode;
  }

  /* -------------------------------------------- */

  /**
   * Handle enabling editing for a spell slot override value.
   * @param {MouseEvent} event    The originating click event.
   * @protected
   */
  async _onSpellSlotOverride(event) {
    const span = event.currentTarget.parentElement;
    const level = span.dataset.level;
    const override = this.actor.system.spells[level].override || span.dataset.slots;
    const input = document.createElement("INPUT");
    input.type = "text";
    input.name = `system.spells.${level}.override`;
    input.value = override;
    input.placeholder = span.dataset.slots;
    input.dataset.dtype = "Number";
    input.addEventListener("focus", event => event.currentTarget.select());

    // Replace the HTML
    const parent = span.parentElement;
    parent.removeChild(span);
    parent.appendChild(input);
  }

  /* -------------------------------------------- */

  /**
   * Handle enabling editing for attunement maximum.
   * @param {MouseEvent} event    The originating click event.
   * @private
   */
  async _onAttunementOverride(event) {
    const span = event.currentTarget.parentElement;
    const input = document.createElement("INPUT");
    input.type = "text";
    input.name = "system.attributes.attunement.max";
    input.value = this.actor.system.attributes.attunement.max;
    input.placeholder = 3;
    input.dataset.dtype = "Number";
    input.addEventListener("focus", event => event.currentTarget.select());

    // Replace the HTML
    const parent = span.parentElement;
    parent.removeChild(span);
    parent.appendChild(input);
  }

  /* -------------------------------------------- */

  /**
   * Initialize attribution tooltips on an element.
   * @param {HTMLElement} element  The tooltipped element.
   * @protected
   */
  _applyAttributionTooltips(element) {
    if ( "tooltip" in element.dataset ) return;
    element.dataset.tooltip = `
      <section class="loading" data-uuid="${this.actor.uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>
    `;
    element.dataset.tooltipClass = "property-attribution";
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling an Ability test or saving throw.
   * @param {Event} event      The originating click event.
   * @private
   */
  _onRollAbilityTest(event) {
    event.preventDefault();
    let ability = event.currentTarget.parentElement.dataset.ability;
    this.actor.rollAbility({ ability, event });
  }

  /* -------------------------------------------- */

  /**
   * Handle rolling a Skill check.
   * @param {Event} event      The originating click event.
   * @returns {Promise<Roll>}  The resulting roll.
   * @private
   */
  _onRollSkillCheck(event) {
    event.preventDefault();
    const skill = event.currentTarget.closest("[data-key]").dataset.key;
    return this.actor.rollSkill({ skill, event });
  }

  /* -------------------------------------------- */

  _onRollToolCheck(event) {
    event.preventDefault();
    const tool = event.currentTarget.closest("[data-key]").dataset.key;
    return this.actor.rollToolCheck({ tool, event });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling Ability score proficiency level.
   * @param {Event} event              The originating click event.
   * @returns {Promise<Actor5e>|void}  Updated actor instance.
   * @private
   */
  _onToggleAbilityProficiency(event) {
    if ( event.currentTarget.classList.contains("disabled") ) return;
    event.preventDefault();
    const field = event.currentTarget.previousElementSibling;
    return this.actor.update({[field.name]: 1 - parseInt(field.value)});
  }

  /* -------------------------------------------- */

  /**
   * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options.
   * @param {Event} event      The click event which originated the selection.
   * @returns {TraitSelector}  Newly displayed application.
   * @private
   */
  _onTraitSelector(event) {
    event.preventDefault();
    const trait = event.currentTarget.dataset.trait;
    const options = { document: this.actor, trait };
    if ( trait === "ci" ) options.position = { width: 400 };
    switch ( trait ) {
      case "di":
      case "dm":
      case "dr":
      case "dv": return new DamagesConfig(options).render({ force: true });
      case "languages": return new LanguagesConfig(options).render({ force: true });
      case "tool": return new ToolsConfig(options).render({ force: true });
      case "weapon": return new WeaponsConfig(options).render({ force: true });
      default: return new TraitsConfig(options).render({ force: true });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle links within preparation warnings.
   * @param {Event} event  The click event on the warning.
   * @protected
   */
  async _onWarningLink(event) {
    event.preventDefault();
    const a = event.target;
    if ( !a || !a.dataset.target ) return;
    switch ( a.dataset.target ) {
      case "armor":
        new ArmorClassConfig({ document: this.actor }).render({ force: true });
        return;
      default:
        const item = await fromUuid(a.dataset.target);
        item?.sheet.render(true);
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _getHeaderButtons() {
    let buttons = super._getHeaderButtons();
    if ( this.actor.isPolymorphed ) {
      buttons.unshift({
        label: "DND5E.PolymorphRestoreTransformation",
        class: "restore-transformation",
        icon: "fas fa-backward",
        onclick: () => this.actor.revertOriginalForm()
      });
    }
    return buttons;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    // Unset any flags which are "false"
    for ( const [k, v] of Object.entries(formData) ) {
      if ( k.startsWith("flags.dnd5e.") && !v ) {
        delete formData[k];
        if ( foundry.utils.hasProperty(this.document._source, k) ) formData[k.replace(/\.([\w\d]+)$/, ".-=$1")] = null;
      }
    }

    // Parent ActorSheet update steps
    return super._updateObject(event, formData);
  }
}

/**
 * An Actor sheet for player character type actors.
 */
class ActorSheet5eCharacter extends ActorSheet5e {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e", "sheet", "actor", "character"]
    });
  }

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options={}) {
    const context = await super.getData(options);

    // Resources
    context.resources = ["primary", "secondary", "tertiary"].reduce((arr, r) => {
      const res = foundry.utils.mergeObject(context.actor.system.resources[r] || {}, {
        name: r,
        placeholder: game.i18n.localize(`DND5E.Resource${r.titleCase()}`)
      }, {inplace: false});
      if ( res.value === 0 ) delete res.value;
      if ( res.max === 0 ) delete res.max;
      return arr.concat([res]);
    }, []);

    const classes = this.actor.itemTypes.class;
    return foundry.utils.mergeObject(context, {
      disableExperience: game.settings.get("dnd5e", "levelingMode") === "noxp",
      classLabels: classes.map(c => c.name).join(", "),
      labels: {
        type: context.system.details.type.label
      },
      multiclassLabels: classes.map(c => [c.subclass?.name ?? "", c.name, c.system.levels].filterJoin(" ")).join(", "),
      weightUnit: game.i18n.localize(`DND5E.Abbreviation${
        game.settings.get("dnd5e", "metricWeightUnits") ? "Kg" : "Lbs"}`),
      encumbrance: context.system.attributes.encumbrance
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareItems(context) {

    // Categorize items as inventory, spellbook, features, and classes
    const inventory = {};
    const inventoryTypes = Object.entries(CONFIG.Item.dataModels)
      .filter(([, model]) => model.metadata?.inventoryItem)
      .sort(([, lhs], [, rhs]) => (lhs.metadata.inventoryOrder - rhs.metadata.inventoryOrder));
    for ( const [type] of inventoryTypes ) {
      inventory[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: {type}};
    }

    // Partition items by category
    let {items, spells, feats, races, backgrounds, classes, subclasses} = context.items.reduce((obj, item) => {
      const { quantity } = item.system;

      // Item details
      const ctx = context.itemContext[item.id] ??= {};
      ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
      if ( item.system.attunement ) ctx.attunement = item.system.attuned ? {
        icon: "fa-sun",
        cls: "attuned",
        title: "DND5E.AttunementAttuned"
      } : {
        icon: "fa-sun",
        cls: "not-attuned",
        title: CONFIG.DND5E.attunementTypes[item.system.attunement]
      };

      // Prepare data needed to display expanded sections
      ctx.isExpanded = this._expanded.has(item.id);

      // Item usage
      ctx.hasRecharge = item.hasRecharge;
      ctx.hasUses = item.hasLimitedUses;

      // Unidentified items
      ctx.concealDetails = !game.user.isGM && (item.system.identified === false);

      // Item grouping
      ctx.ungroup = "passive";
      const [originId] = item.getFlag("dnd5e", "advancementOrigin")?.split(".") ?? [];
      const group = this.actor.items.get(originId);
      switch ( group?.type ) {
        case "race": ctx.group = "race"; break;
        case "background": ctx.group = "background"; break;
        case "class": ctx.group = group.identifier; break;
        case "subclass": ctx.group = group.class?.identifier ?? "other"; break;
        default: ctx.group = "other";
      }

      // Individual item preparation
      this._prepareItem(item, ctx);

      // Classify items into types
      if ( item.type === "spell" ) obj.spells.push(item);
      else if ( item.type === "feat" ) obj.feats.push(item);
      else if ( item.type === "race" ) obj.races.push(item);
      else if ( item.type === "background" ) obj.backgrounds.push(item);
      else if ( item.type === "class" ) obj.classes.push(item);
      else if ( item.type === "subclass" ) obj.subclasses.push(item);
      else if ( Object.keys(inventory).includes(item.type) ) obj.items.push(item);
      return obj;
    }, { items: [], spells: [], feats: [], races: [], backgrounds: [], classes: [], subclasses: [] });

    // Organize items
    for ( let i of items ) {
      const ctx = context.itemContext[i.id] ??= {};
      ctx.totalWeight = i.system.totalWeight?.toNearest(0.1);
      inventory[i.type].items.push(i);
    }

    // Organize Spellbook and count the number of prepared spells (excluding always, at will, etc...)
    const spellbook = this._prepareSpellbook(context, spells);
    const nPrepared = spells.filter(spell => {
      const prep = spell.system.preparation;
      return (spell.system.level > 0) && (prep.mode === "prepared") && prep.prepared;
    }).length;

    // Sort classes and interleave matching subclasses, put unmatched subclasses into features so they don't disappear
    classes.sort((a, b) => b.system.levels - a.system.levels);
    const maxLevelDelta = CONFIG.DND5E.maxLevel - this.actor.system.details.level;
    classes = classes.reduce((arr, cls) => {
      const ctx = context.itemContext[cls.id] ??= {};
      ctx.availableLevels = Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1).map(level => {
        const delta = level - cls.system.levels;
        let label = formatNumber(level);
        if ( delta ) label = `${label} (${formatNumber(delta, { signDisplay: "always" })})`;
        return { value: delta, label, disabled: delta > maxLevelDelta };
      });
      ctx.prefixedImage = cls.img ? foundry.utils.getRoute(cls.img) : null;
      arr.push(cls);
      const identifier = cls.system.identifier || cls.name.slugify({strict: true});
      const subclass = subclasses.findSplice(s => s.system.classIdentifier === identifier);
      if ( subclass ) arr.push(subclass);
      else {
        const subclassAdvancement = cls.advancement.byType.Subclass?.[0];
        if ( subclassAdvancement && (subclassAdvancement.level <= cls.system.levels) ) ctx.needsSubclass = true;
      }
      return arr;
    }, []);
    for ( const subclass of subclasses ) {
      feats.push(subclass);
      const message = game.i18n.format("DND5E.SubclassMismatchWarn", {
        name: subclass.name, class: subclass.system.classIdentifier
      });
      context.warnings.push({ message, type: "warning" });
    }

    // Organize Features
    const features = {
      race: {
        label: CONFIG.Item.typeLabels.race, items: races,
        hasActions: false, dataset: {type: "race"} },
      background: {
        label: CONFIG.Item.typeLabels.background, items: backgrounds,
        hasActions: false, dataset: {type: "background"} },
      classes: {
        label: `${CONFIG.Item.typeLabels.class}Pl`, items: classes,
        hasActions: false, dataset: {type: "class"}, isClass: true },
      active: {
        label: "DND5E.FeatureActive", items: [],
        hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
      passive: {
        label: "DND5E.FeaturePassive", items: [],
        hasActions: false, dataset: {type: "feat"} }
    };
    for ( const feat of feats ) {
      if ( feat.system.activities?.size && !feat.system.properties?.has("trait") ) {
        features.active.items.push(feat);
        context.itemContext[feat.id].ungroup = "active";
      }
      else features.passive.items.push(feat);
    }

    // Assign and return
    context.inventoryFilters = true;
    context.inventory = Object.values(inventory);
    context.spellbook = spellbook;
    context.preparedSpells = nPrepared;
    context.features = Object.values(features);
  }

  /* -------------------------------------------- */

  /**
   * A helper method to establish the displayed preparation state for an item.
   * @param {Item5e} item     Item being prepared for display.
   * @param {object} context  Context data for display.
   * @protected
   */
  _prepareItem(item, context) {
    if ( item.type === "spell" ) {
      const prep = item.system.preparation || {};
      const isAlways = prep.mode === "always";
      const isPrepared = !!prep.prepared;
      context.toggleClass = isPrepared ? "active" : "";
      if ( isAlways ) context.toggleClass = "fixed";
      if ( isAlways ) context.toggleTitle = CONFIG.DND5E.spellPreparationModes.always.label;
      else if ( isPrepared ) context.toggleTitle = CONFIG.DND5E.spellPreparationModes.prepared.label;
      else context.toggleTitle = game.i18n.localize("DND5E.SpellUnprepared");
    }
    else {
      const isActive = !!item.system.equipped;
      context.toggleClass = isActive ? "active" : "";
      context.toggleTitle = game.i18n.localize(isActive ? "DND5E.Equipped" : "DND5E.Unequipped");
      context.canToggle = "equipped" in item.system;
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    if ( !this.isEditable ) return;
    html.find(".short-rest").click(this._onShortRest.bind(this));
    html.find(".long-rest").click(this._onLongRest.bind(this));
    html.find(".rollable[data-action]").click(this._onSheetAction.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onConfigMenu(event) {
    event.preventDefault();
    event.stopPropagation();
    if ( (event.currentTarget.dataset.action === "type") && (this.actor.system.details.race?.id) ) {
      new CreatureTypeConfig({ document: this.actor.system.details.race, keyPath: "type" }).render({ force: true });
    } else if ( event.currentTarget.dataset.action !== "type" ) {
      return super._onConfigMenu(event);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse click events for character sheet actions.
   * @param {MouseEvent} event  The originating click event.
   * @returns {Promise}         Dialog or roll result.
   * @protected
   */
  _onSheetAction(event) {
    event.preventDefault();
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "convertCurrency":
        return Dialog.confirm({
          title: `${game.i18n.localize("DND5E.CurrencyConvert")}`,
          content: `<p>${game.i18n.localize("DND5E.CurrencyConvertHint")}</p>`,
          yes: () => this.actor.convertCurrency()
        });
      case "rollDeathSave":
        return this.actor.rollDeathSave({ event, legacy: false });
      case "rollInitiative":
        return this.actor.rollInitiativeDialog({ event });
    }
  }

  /* -------------------------------------------- */

  /**
   * Take a short rest, calling the relevant function on the Actor instance.
   * @param {Event} event             The triggering click event.
   * @returns {Promise<RestResult>}  Result of the rest action.
   * @private
   */
  async _onShortRest(event) {
    event.preventDefault();
    await this._onSubmit(event);
    return this.actor.shortRest();
  }

  /* -------------------------------------------- */

  /**
   * Take a long rest, calling the relevant function on the Actor instance.
   * @param {Event} event             The triggering click event.
   * @returns {Promise<RestResult>}  Result of the rest action.
   * @private
   */
  async _onLongRest(event) {
    event.preventDefault();
    await this._onSubmit(event);
    return this.actor.longRest();
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropSingleItem(itemData, event) {

    // Increment the number of class levels a character instead of creating a new item
    if ( itemData.type === "class" ) {
      const charLevel = this.actor.system.details.level;
      itemData.system.levels = Math.min(itemData.system.levels, CONFIG.DND5E.maxLevel - charLevel);
      if ( itemData.system.levels <= 0 ) {
        const err = game.i18n.format("DND5E.MaxCharacterLevelExceededWarn", { max: CONFIG.DND5E.maxLevel });
        ui.notifications.error(err);
        return false;
      }

      const cls = this.actor.itemTypes.class.find(c => c.identifier === itemData.system.identifier);
      if ( cls ) {
        const priorLevel = cls.system.levels;
        if ( !game.settings.get("dnd5e", "disableAdvancements") ) {
          const manager = AdvancementManager.forLevelChange(this.actor, cls.id, itemData.system.levels);
          if ( manager.steps.length ) {
            manager.render(true);
            return false;
          }
        }
        cls.update({"system.levels": priorLevel + itemData.system.levels});
        return false;
      }
    }

    // If a subclass is dropped, ensure it doesn't match another subclass with the same identifier
    else if ( itemData.type === "subclass" ) {
      const other = this.actor.itemTypes.subclass.find(i => i.identifier === itemData.system.identifier);
      if ( other ) {
        const err = game.i18n.format("DND5E.SubclassDuplicateError", {identifier: other.identifier});
        ui.notifications.error(err);
        return false;
      }
      const cls = this.actor.itemTypes.class.find(i => i.identifier === itemData.system.classIdentifier);
      if ( cls && cls.subclass ) {
        const err = game.i18n.format("DND5E.SubclassAssignmentError", {class: cls.name, subclass: cls.subclass.name});
        ui.notifications.error(err);
        return false;
      }
    }
    return super._onDropSingleItem(itemData, event);
  }
}

/**
 * Object describing the hit dice for an actor.
 */
class HitDice {
  /**
   * Object describing the hit dice for an actor.
   * @param {Actor5e} actor     The actor whose hit dice this document describes.
   */
  constructor(actor) {
    this.actor = actor;

    for ( const item of Object.values(actor.classes) ) {
      this.classes.add(item);
      this.sizes.add(parseInt(item.system.hd.denomination.slice(1)));
    }
  }

  /* -------------------------------------------- */

  /**
   * Store a reference to the actor.
   * @type {Actor5e}
   */
  actor = null;

  /* -------------------------------------------- */

  /**
   * Remaining hit dice.
   * @type {number}
   */
  get value() {
    if ( this.#value !== undefined ) return this.#value;
    this.#value = this.classes.reduce((acc, cls) => acc + cls.system.hd.value, 0);
    return this.#value;
  }

  #value;

  /* -------------------------------------------- */

  /**
   * The actor's total amount of hit dice.
   * @type {number}
   */
  get max() {
    if ( this.#max !== undefined ) return this.#max;
    this.#max = this.classes.reduce((acc, cls) => acc + cls.system.hd.max, 0);
    return this.#max;
  }

  #max;

  /* -------------------------------------------- */

  /**
   * All valid die sizes derived from all classes.
   * @type {Set<number>}
   */
  sizes = new Set();

  /* -------------------------------------------- */

  /**
   * Store valid class items.
   * @type {Set<Item5e>}
   */
  classes = new Set();

  /* -------------------------------------------- */

  /**
   * The smallest denomination.
   * @type {string}
   */
  get smallest() {
    return `d${this.smallestFace}`;
  }

  /* -------------------------------------------- */

  /**
   * The smallest die size of those available.
   * @type {string}
   */
  get smallestAvailable() {
    const bySize = this.bySize;
    for ( const faces of Array.from(this.sizes).sort((a, b) => a - b) ) {
      if ( bySize[`d${faces}`] ) return `d${faces}`;
    }
    return "d0";
  }

  /* -------------------------------------------- */

  /**
   * The smallest die size.
   * @type {number}
   */
  get smallestFace() {
    return this.sizes.size ? Math.min(...this.sizes) : 0;
  }

  /* -------------------------------------------- */

  /**
   * The largest denomination.
   * @type {string}
   */
  get largest() {
    return `d${this.largestFace}`;
  }

  /* -------------------------------------------- */

  /**
   * The largest die size of those available.
   * @type {string}
   */
  get largestAvailable() {
    const bySize = this.bySize;
    for ( const faces of Array.from(this.sizes).sort((a, b) => b - a) ) {
      if ( bySize[`d${faces}`] ) return `d${faces}`;
    }
    return "d0";
  }

  /* -------------------------------------------- */

  /**
   * The largest die size.
   * @type {number}
   */
  get largestFace() {
    return this.sizes.size ? Math.max(...this.sizes) : 0;
  }

  /* -------------------------------------------- */

  /**
   * The percentage of remaining hit dice.
   * @type {number}
   */
  get pct() {
    return Math.clamp(this.max ? (this.value / this.max) * 100 : 0, 0, 100);
  }

  /* -------------------------------------------- */

  /**
   * Return an object of remaining hit dice categorized by size.
   * @returns {object}
   */
  get bySize() {
    const hd = {};
    this.classes.forEach(cls => {
      const d = cls.system.hd.denomination;
      hd[d] = (hd[d] ?? 0) + cls.system.hd.value;
    });
    return hd;
  }

  /* -------------------------------------------- */

  /**
   * Override the default `toString` method for backwards compatibility.
   * @returns {number}    Remaining hit dice.
   */
  toString() {
    return this.value;
  }

  /* -------------------------------------------- */

  /**
   * Create item updates for recovering hit dice during a rest.
   * @param {RestConfiguration} [config]
   * @param {number} [config.maxHitDice]    Maximum number of hit dice to recover.
   * @param {number} [config.fraction=0.5]  Fraction of max hit dice to recover. Only used if
   *                                        `maxHitDice` isn't specified.
   * @param {boolean} [config.largest]      Whether to restore the largest hit dice first.
   * @param {RestResult} [result={}]        Rest result being constructed.
   */
  createHitDiceUpdates({ maxHitDice, fraction=0.5, largest=true, ...config }={}, result={}) {
    if ( !Number.isInteger(maxHitDice) ) maxHitDice = Math.max(Math.floor(this.max * fraction), 1);
    const classes = Array.from(this.classes).sort((lhs, rhs) => {
      const sort = lhs.system.hd.denomination.localeCompare(rhs.system.hd.denomination, "en", { numeric: true });
      return largest ? sort * -1 : sort;
    });
    const updateItems = [];
    let recovered = 0;
    for ( const item of classes ) {
      const used = item.system.hd.spent;
      if ( (recovered < maxHitDice) && (used > 0) ) {
        const delta = Math.min(used, maxHitDice - recovered);
        recovered += delta;
        updateItems.push({ _id: item.id, "system.hd.spent": used - delta });
      }
    }
    foundry.utils.mergeObject(result, {
      deltas: {
        hitDice: (result?.deltas?.hitDice ?? 0) + recovered
      },
      updateItems
    });
  }
}

const { StringField: StringField$v } = foundry.data.fields;

/**
 * Field for storing creature type data.
 */
class CreatureTypeField extends foundry.data.fields.SchemaField {
  constructor(fields={}, options={}) {
    fields = {
      value: new StringField$v({ blank: true, label: "DND5E.CreatureType" }),
      subtype: new StringField$v({ label: "DND5E.CreatureTypeSelectorSubtype" }),
      swarm: new StringField$v({ blank: true, label: "DND5E.CreatureSwarmSize" }),
      custom: new StringField$v({ label: "DND5E.CreatureTypeSelectorCustom" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.CreatureType", ...options });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  initialize(value, model, options={}) {
    const obj = super.initialize(value, model, options);

    Object.defineProperty(obj, "label", {
      get() {
        return dnd5e.documents.Actor5e.formatCreatureType(this);
      },
      enumerable: false
    });
    Object.defineProperty(obj, "config", {
      get() {
        return CONFIG.DND5E.creatureTypes[this.value];
      },
      enumerable: false
    });

    return obj;
  }
}

const { StringField: StringField$u, NumberField: NumberField$k, SchemaField: SchemaField$n } = foundry.data.fields;

/**
 * @typedef {object} RollConfigData
 * @property {string} [ability]  Default ability associated with this roll.
 * @property {object} roll
 * @property {number} roll.min   Minimum number on the die rolled.
 * @property {number} roll.max   Maximum number on the die rolled.
 * @property {number} roll.mode  Should the roll be with disadvantage or advantage by default?
 */

/**
 * Field for storing data for a specific type of roll.
 */
class RollConfigField extends foundry.data.fields.SchemaField {
  constructor({roll={}, ability="", ...fields}={}, options={}) {
    const opts = { initial: null, nullable: true, min: 1, max: 20, integer: true };
    fields = {
      ability: (ability === false) ? null : new StringField$u({
        required: true,
        initial: ability,
        label: "DND5E.AbilityModifier"
      }),
      roll: new SchemaField$n({
        min: new NumberField$k({...opts, label: "DND5E.ROLL.Range.Minimum"}),
        max: new NumberField$k({...opts, label: "DND5E.ROLL.Range.Maximum"}),
        mode: new AdvantageModeField(),
        ...roll
      }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, options);
  }
}

const { SchemaField: SchemaField$m, SetField: SetField$f, StringField: StringField$t } = foundry.data.fields;

/**
 * Data structure for a standard actor trait.
 *
 * @typedef {object} SimpleTraitData
 * @property {Set<string>} value  Keys for currently selected traits.
 * @property {string} custom      Semicolon-separated list of custom traits.
 */

/**
 * Field for storing standard trait data.
 */
class SimpleTraitField extends SchemaField$m {
  constructor(fields={}, { initialValue=[], ...options }={}) {
    fields = {
      value: new SetField$f(new StringField$t(), { label: "DND5E.TraitsChosen", initial: initialValue }),
      custom: new StringField$t({ required: true, label: "DND5E.Special" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, options);
  }
}

const { BooleanField: BooleanField$b, NumberField: NumberField$j, StringField: StringField$s } = foundry.data.fields;

/**
 * @typedef {object} MovementData
 * @property {number} burrow  Actor burrowing speed.
 * @property {number} climb   Actor climbing speed.
 * @property {number} fly     Actor flying speed.
 * @property {number} swim    Actor swimming speed.
 * @property {number} walk    Actor walking speed.
 * @property {string} units   Movement used to measure the various speeds.
 * @property {boolean} hover  This flying creature able to hover in place.
 */

/**
 * Field for storing movement data.
 */
class MovementField extends foundry.data.fields.SchemaField {
  constructor(fields={}, { initialUnits=null, ...options }={}) {
    const numberConfig = { required: true, nullable: true, min: 0, step: 0.1, initial: null };
    fields = {
      burrow: new NumberField$j({ ...numberConfig, label: "DND5E.MovementBurrow" }),
      climb: new NumberField$j({ ...numberConfig, label: "DND5E.MovementClimb" }),
      fly: new NumberField$j({ ...numberConfig, label: "DND5E.MovementFly" }),
      swim: new NumberField$j({ ...numberConfig, label: "DND5E.MovementSwim" }),
      walk: new NumberField$j({ ...numberConfig, label: "DND5E.MovementWalk" }),
      units: new StringField$s({
        required: true, nullable: true, blank: false, initial: initialUnits, label: "DND5E.MovementUnits"
      }),
      hover: new BooleanField$b({ required: true, label: "DND5E.MovementHover" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.Movement", ...options });
  }
}

const { NumberField: NumberField$i, StringField: StringField$r } = foundry.data.fields;

/**
 * @typedef {object} SensesData
 * @property {number} darkvision       Creature's darkvision range.
 * @property {number} blindsight       Creature's blindsight range.
 * @property {number} tremorsense      Creature's tremorsense range.
 * @property {number} truesight        Creature's truesight range.
 * @property {string} units            Distance units used to measure senses.
 * @property {string} special          Description of any special senses or restrictions.
 */

/**
 * Field for storing senses data.
 */
class SensesField extends foundry.data.fields.SchemaField {
  constructor(fields={}, { initialUnits=null, ...options }={}) {
    const numberConfig = { required: true, nullable: true, integer: true, min: 0, initial: null };
    fields = {
      darkvision: new NumberField$i({ ...numberConfig, label: "DND5E.SenseDarkvision" }),
      blindsight: new NumberField$i({ ...numberConfig, label: "DND5E.SenseBlindsight" }),
      tremorsense: new NumberField$i({ ...numberConfig, label: "DND5E.SenseTremorsense" }),
      truesight: new NumberField$i({ ...numberConfig, label: "DND5E.SenseTruesight" }),
      units: new StringField$r({
        required: true, nullable: true, blank: false, initial: initialUnits, label: "DND5E.SenseUnits"
      }),
      special: new StringField$r({ required: true, label: "DND5E.SenseSpecial" }),
      ...fields
    };
    Object.entries(fields).forEach(([k, v]) => !v ? delete fields[k] : null);
    super(fields, { label: "DND5E.Senses", ...options });
  }
}

const { NumberField: NumberField$h, SchemaField: SchemaField$l, StringField: StringField$q } = foundry.data.fields;

/**
 * @import { MovementData } from "../../shared/movement-field.mjs"
 * @import { RollConfigData } from "../../shared/roll-config-field.mjs"
 * @import { SensesData } from "../../shared/senses-field.mjs"
 */

/**
 * @typedef {object} ArmorClassData
 * @property {string} calc     Name of one of the built-in formulas to use.
 * @property {number} flat     Flat value used for flat or natural armor calculation.
 * @property {string} formula  Custom formula to use.
 */

/**
 * Shared contents of the attributes schema between various actor types.
 */
class AttributesFields {
  /**
   * Armor class fields shared between characters, NPCs, and vehicles.
   *
   * @type {ArmorClassData}
   */
  static get armorClass() {
    return {
      calc: new StringField$q({ initial: "default", label: "DND5E.ArmorClassCalculation" }),
      flat: new NumberField$h({ integer: true, min: 0, label: "DND5E.ArmorClassFlat" }),
      formula: new FormulaField({ deterministic: true, label: "DND5E.ArmorClassFormula" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters, NPCs, and vehicles.
   *
   * @type {object}
   * @property {ArmorClassData} ac       Armor class configuration.
   * @property {RollConfigData} init
   * @property {string} init.ability     The ability used for initiative rolls.
   * @property {string} init.bonus       The bonus provided to initiative rolls.
   * @property {MovementData} movement
   */
  static get common() {
    return {
      ac: new SchemaField$l(this.armorClass, { label: "DND5E.ArmorClass" }),
      init: new RollConfigField({
        ability: "",
        bonus: new FormulaField({ required: true, label: "DND5E.InitiativeBonus" })
      }, { label: "DND5E.Initiative" }),
      movement: new MovementField()
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters and NPCs.
   *
   * @type {object}
   * @property {object} attunement
   * @property {number} attunement.max              Maximum number of attuned items.
   * @property {SensesData} senses
   * @property {string} spellcasting                Primary spellcasting ability.
   * @property {number} exhaustion                  Creature's exhaustion level.
   * @property {RollConfigData} concentration
   * @property {string} concentration.ability       The ability used for concentration saving throws.
   * @property {object} concentration.bonuses
   * @property {string} concentration.bonuses.save  The bonus provided to concentration saving throws.
   * @property {number} concentration.limit         The amount of items this actor can concentrate on.
   * @property {object} loyalty
   * @property {number} loyalty.value               The creature's loyalty score.
   */
  static get creature() {
    return {
      attunement: new SchemaField$l({
        max: new NumberField$h({
          required: true, nullable: false, integer: true, min: 0, initial: 3, label: "DND5E.AttunementMax"
        })
      }, { label: "DND5E.Attunement" }),
      senses: new SensesField(),
      spellcasting: new StringField$q({ required: true, blank: true, label: "DND5E.SpellAbility" }),
      exhaustion: new NumberField$h({
        required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.Exhaustion"
      }),
      concentration: new RollConfigField({
        ability: "",
        bonuses: new SchemaField$l({
          save: new FormulaField({ required: true, label: "DND5E.ConcentrationBonus" })
        }),
        limit: new NumberField$h({ integer: true, min: 0, initial: 1, label: "DND5E.ConcentrationLimit" })
      }, { label: "DND5E.Concentration" }),
      loyalty: new SchemaField$l({
        value: new NumberField$h({ integer: true, min: 0, max: 20, label: "DND5E.Loyalty" })
      })
    };
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /**
   * Migrate the old init.value and incorporate it into init.bonus.
   * @param {object} source  The source attributes object.
   * @internal
   */
  static _migrateInitiative(source) {
    const init = source?.init;
    if ( !init?.value || (typeof init?.bonus === "string") ) return;
    if ( init.bonus ) init.bonus += init.value < 0 ? ` - ${init.value * -1}` : ` + ${init.value}`;
    else init.bonus = `${init.value}`;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Initialize derived AC fields for Active Effects to target.
   * @this {CharacterData|NPCData|VehicleData}
   */
  static prepareBaseArmorClass() {
    const ac = this.attributes.ac;
    ac.armor = 10;
    ac.shield = ac.cover = 0;
    ac.min = ac.bonus = "";
  }

  /* -------------------------------------------- */

  /**
   * Initialize base encumbrance fields to be targeted by active effects.
   * @this {CharacterData|NPCData|VehicleData}
   */
  static prepareBaseEncumbrance() {
    const encumbrance = this.attributes.encumbrance ??= {};
    encumbrance.multipliers = { encumbered: "1", heavilyEncumbered: "1", maximum: "1", overall: "1" };
    encumbrance.bonuses = { encumbered: "", heavilyEncumbered: "", maximum: "", overall: "" };
  }

  /* -------------------------------------------- */

  /**
   * Prepare a character's AC value from their equipped armor and shield.
   * @this {CharacterData|NPCData|VehicleData}
   * @param {object} rollData  The Actor's roll data.
   */
  static prepareArmorClass(rollData) {
    const ac = this.attributes.ac;

    // Apply automatic migrations for older data structures
    let cfg = CONFIG.DND5E.armorClasses[ac.calc];
    if ( !cfg ) {
      ac.calc = "flat";
      if ( Number.isNumeric(ac.value) ) ac.flat = Number(ac.value);
      cfg = CONFIG.DND5E.armorClasses.flat;
    }

    // Identify Equipped Items
    const { armors, shields } = this.parent.itemTypes.equipment.reduce((obj, equip) => {
      if ( !equip.system.equipped || !(equip.system.type.value in CONFIG.DND5E.armorTypes)) return obj;
      if ( equip.system.type.value === "shield" ) obj.shields.push(equip);
      else obj.armors.push(equip);
      return obj;
    }, { armors: [], shields: [] });

    // Determine base AC
    switch ( ac.calc ) {

      // Flat AC (no additional bonuses)
      case "flat":
        ac.value = Number(ac.flat);
        return;

      // Natural AC (includes bonuses)
      case "natural":
        ac.base = Number(ac.flat);
        break;

      default:
        let formula = ac.calc === "custom" ? ac.formula : cfg.formula;
        if ( armors.length ) {
          if ( armors.length > 1 ) this.parent._preparationWarnings.push({
            message: game.i18n.localize("DND5E.WarnMultipleArmor"), type: "warning"
          });
          const armorData = armors[0].system.armor;
          const isHeavy = armors[0].system.type.value === "heavy";
          ac.armor = armorData.value ?? ac.armor;
          ac.dex = isHeavy ? 0 : Math.min(armorData.dex ?? Infinity, this.abilities.dex?.mod ?? 0);
          ac.equippedArmor = armors[0];
        }
        else ac.dex = this.abilities.dex?.mod ?? 0;

        rollData.attributes.ac = ac;
        try {
          const replaced = replaceFormulaData(formula, rollData, {
            actor: this, missing: null, property: game.i18n.localize("DND5E.ArmorClass")
          });
          ac.base = replaced ? new Roll(replaced).evaluateSync().total : 0;
        } catch(err) {
          this.parent._preparationWarnings.push({
            message: game.i18n.format("DND5E.WarnBadACFormula", { formula }), link: "armor", type: "error"
          });
          const replaced = Roll.replaceFormulaData(CONFIG.DND5E.armorClasses.default.formula, rollData);
          ac.base = new Roll(replaced).evaluateSync().total;
        }
        break;
    }

    // Equipped Shield
    if ( shields.length ) {
      if ( shields.length > 1 ) this.parent._preparationWarnings.push({
        message: game.i18n.localize("DND5E.WarnMultipleShields"), type: "warning"
      });
      ac.shield = shields[0].system.armor.value ?? 0;
      ac.equippedShield = shields[0];
    }

    // Compute cover.
    ac.cover = Math.max(ac.cover, this.parent.coverBonus);

    // Compute total AC and return
    ac.min = simplifyBonus(ac.min, rollData);
    ac.bonus = simplifyBonus(ac.bonus, rollData);
    ac.value = Math.max(ac.min, ac.base + ac.shield + ac.bonus + ac.cover);
  }

  /* -------------------------------------------- */

  /**
   * Prepare concentration data for an Actor.
   * @this {CharacterData|NPCData}
   * @param {object} rollData  The Actor's roll data.
   */
  static prepareConcentration(rollData) {
    const { concentration } = this.attributes;
    const abilityId = concentration.ability || CONFIG.DND5E.defaultAbilities.concentration;
    const ability = this.abilities?.[abilityId] || {};
    const bonus = simplifyBonus(concentration.bonuses.save, rollData);
    concentration.save = (ability.save?.value ?? 0) + bonus;
  }

  /* -------------------------------------------- */

  /**
   * Calculate encumbrance details for an Actor.
   * @this {CharacterData|NPCData|VehicleData}
   * @param {object} rollData  The Actor's roll data.
   * @param {object} [options]
   * @param {Function} [options.validateItem]  Determine whether an item's weight should count toward encumbrance.
   */
  static prepareEncumbrance(rollData, { validateItem }={}) {
    const config = CONFIG.DND5E.encumbrance;
    const encumbrance = this.attributes.encumbrance ??= {};
    const baseUnits = CONFIG.DND5E.encumbrance.baseUnits[this.parent.type]
      ?? CONFIG.DND5E.encumbrance.baseUnits.default;
    const unitSystem = game.settings.get("dnd5e", "metricWeightUnits") ? "metric" : "imperial";

    // Get the total weight from items
    let weight = this.parent.items
      .filter(item => !item.container && (validateItem?.(item) ?? true))
      .reduce((weight, item) => weight + (item.system.totalWeightIn?.(baseUnits[unitSystem]) ?? 0), 0);

    // [Optional] add Currency Weight (for non-transformed actors)
    const currency = this.currency;
    if ( game.settings.get("dnd5e", "currencyWeight") && currency ) {
      const numCoins = Object.values(currency).reduce((val, denom) => val + Math.max(denom, 0), 0);
      const currencyPerWeight = config.currencyPerWeight[unitSystem];
      weight += convertWeight(
        numCoins / currencyPerWeight,
        config.baseUnits.default[unitSystem],
        baseUnits[unitSystem]
      );
    }

    // Determine the Encumbrance size class
    const keys = Object.keys(CONFIG.DND5E.actorSizes);
    const index = keys.findIndex(k => k === this.traits.size);
    const sizeConfig = CONFIG.DND5E.actorSizes[
      keys[this.parent.flags.dnd5e?.powerfulBuild ? Math.min(index + 1, keys.length - 1) : index]
    ];
    const sizeMod = sizeConfig?.capacityMultiplier ?? sizeConfig?.token ?? 1;
    let maximumMultiplier;

    const calculateThreshold = threshold => {
      let base = this.abilities.str?.value ?? 10;
      const bonus = simplifyBonus(encumbrance.bonuses?.[threshold], rollData)
        + simplifyBonus(encumbrance.bonuses?.overall, rollData);
      let multiplier = simplifyBonus(encumbrance.multipliers[threshold], rollData)
        * simplifyBonus(encumbrance.multipliers.overall, rollData);
      if ( threshold === "maximum" ) maximumMultiplier = multiplier;
      if ( this.parent.type === "vehicle" ) base = this.attributes.capacity.cargo;
      else multiplier *= (config.threshold[threshold]?.[unitSystem] ?? 1) * sizeMod;
      return (base * multiplier).toNearest(0.1) + bonus;
    };

    // Populate final Encumbrance values
    encumbrance.value = weight.toNearest(0.1);
    encumbrance.thresholds = {
      encumbered: calculateThreshold("encumbered"),
      heavilyEncumbered: calculateThreshold("heavilyEncumbered"),
      maximum: calculateThreshold("maximum")
    };
    encumbrance.max = encumbrance.thresholds.maximum;
    encumbrance.mod = (sizeMod * maximumMultiplier).toNearest(0.1);
    encumbrance.stops = {
      encumbered: Math.clamp((encumbrance.thresholds.encumbered * 100) / encumbrance.max, 0, 100),
      heavilyEncumbered: Math.clamp((encumbrance.thresholds.heavilyEncumbered * 100) / encumbrance.max, 0, 100)
    };
    encumbrance.pct = Math.clamp((encumbrance.value * 100) / encumbrance.max, 0, 100);
    encumbrance.encumbered = encumbrance.value > encumbrance.heavilyEncumbered;
  }

  /* -------------------------------------------- */

  /**
   * Adjust exhaustion level based on Active Effects.
   * @this {CharacterData|NPCData}
   */
  static prepareExhaustionLevel() {
    const exhaustion = this.parent.effects.get(ActiveEffect5e.ID.EXHAUSTION);
    const level = exhaustion?.getFlag("dnd5e", "exhaustionLevel");
    this.attributes.exhaustion = Number.isFinite(level) ? level : 0;
  }

  /* -------------------------------------------- */

  /**
   * Calculate maximum hit points, taking an provided advancement into consideration.
   * @param {object} hp                 HP object to calculate.
   * @param {object} [options={}]
   * @param {HitPointsAdvancement[]} [options.advancement=[]]  Advancement items from which to get hit points per-level.
   * @param {number} [options.bonus=0]  Additional bonus to add atop the calculated value.
   * @param {number} [options.mod=0]    Modifier for the ability to add to hit points from advancement.
   * @this {ActorDataModel}
   */
  static prepareHitPoints(hp, { advancement=[], mod=0, bonus=0 }={}) {
    const base = advancement.reduce((total, advancement) => total + advancement.getAdjustedTotal(mod), 0);
    hp.max = (hp.max ?? 0) + base + bonus;
    if ( this.parent.hasConditionEffect("halfHealth") ) hp.max = Math.floor(hp.max * 0.5);

    hp.effectiveMax = hp.max + (hp.tempmax ?? 0);
    hp.value = Math.min(hp.value, hp.effectiveMax);
    hp.damage = hp.effectiveMax - hp.value;
    hp.pct = Math.clamp(hp.effectiveMax ? (hp.value / hp.effectiveMax) * 100 : 0, 0, 100);
  }

  /* -------------------------------------------- */

  /**
   * Prepare the initiative data for an actor.
   * @this {CharacterData|NPCData|VehicleData}
   * @param {object} rollData  The Actor's roll data.
   */
  static prepareInitiative(rollData) {
    const init = this.attributes.init ??= {};
    const flags = this.parent.flags.dnd5e ?? {};
    const globalCheckBonus = simplifyBonus(this.bonuses?.abilities?.check, rollData);

    // Compute initiative modifier
    const abilityId = init.ability || CONFIG.DND5E.defaultAbilities.initiative;
    const ability = this.abilities?.[abilityId] || {};
    init.mod = ability.mod ?? 0;

    // Initiative proficiency
    const isLegacy = game.settings.get("dnd5e", "rulesVersion") === "legacy";
    const prof = this.attributes.prof ?? 0;
    const joat = flags.jackOfAllTrades && isLegacy;
    const ra = this.parent._isRemarkableAthlete(abilityId);
    const alert = flags.initiativeAlert && !isLegacy;
    init.prof = new Proficiency(prof, alert ? 1 : (joat || ra) ? 0.5 : 0, !ra);

    // Total initiative includes all numeric terms
    const initBonus = simplifyBonus(init.bonus, rollData);
    const abilityBonus = simplifyBonus(ability.bonuses?.check, rollData);
    init.total = init.mod + initBonus + abilityBonus + globalCheckBonus
      + (flags.initiativeAlert && isLegacy ? 5 : 0)
      + (Number.isNumeric(init.prof.term) ? init.prof.flat : 0);
    init.score = 10 + init.total;
  }

  /* -------------------------------------------- */

  /**
   * Modify movement speeds taking exhaustion and any other conditions into account.
   * @this {CharacterData|NPCData}
   */
  static prepareMovement() {
    const statuses = this.parent.statuses;
    const noMovement = this.parent.hasConditionEffect("noMovement");
    const halfMovement = this.parent.hasConditionEffect("halfMovement");
    const encumbered = statuses.has("encumbered");
    const heavilyEncumbered = statuses.has("heavilyEncumbered");
    const exceedingCarryingCapacity = statuses.has("exceedingCarryingCapacity");
    const crawl = this.parent.hasConditionEffect("crawl");
    const units = this.attributes.movement.units ??= defaultUnits("length");
    let reduction = game.settings.get("dnd5e", "rulesVersion") === "modern"
      ? (this.attributes.exhaustion ?? 0) * (CONFIG.DND5E.conditionTypes.exhaustion?.reduction?.speed ?? 0) : 0;
    reduction = convertLength(reduction, CONFIG.DND5E.defaultUnits.length.imperial, units);
    for ( const type in CONFIG.DND5E.movementTypes ) {
      let speed = Math.max(0, this.attributes.movement[type] - reduction);
      if ( noMovement || (crawl && (type !== "walk")) ) speed = 0;
      else {
        if ( halfMovement ) speed *= 0.5;
        if ( heavilyEncumbered ) {
          speed = Math.max(0, speed - (CONFIG.DND5E.encumbrance.speedReduction.heavilyEncumbered[units] ?? 0));
        } else if ( encumbered ) {
          speed = Math.max(0, speed - (CONFIG.DND5E.encumbrance.speedReduction.encumbered[units] ?? 0));
        }
        if ( exceedingCarryingCapacity ) {
          speed = Math.min(speed, CONFIG.DND5E.encumbrance.speedReduction.exceedingCarryingCapacity[units] ?? 0);
        }
      }
      this.attributes.movement[type] = speed;
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply movement and sense changes based on a race item. This method should be called during
   * the `prepareEmbeddedData` step of data preparation.
   * @param {Item5e} race                    Race item from which to get the stats.
   * @param {object} [options={}]
   * @param {boolean} [options.force=false]  Override any values on the actor.
   * @this {CharacterData|NPCData}
   */
  static prepareRace(race, { force=false }={}) {
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) {
      if ( !race.system.movement[key] || (!force && (this.attributes.movement[key] !== null)) ) continue;
      this.attributes.movement[key] = race.system.movement[key];
    }
    if ( race.system.movement.hover ) this.attributes.movement.hover = true;
    if ( force && race.system.movement.units ) this.attributes.movement.units = race.system.movement.units;
    else this.attributes.movement.units ??= race.system.movement.units;

    for ( const key of Object.keys(CONFIG.DND5E.senses) ) {
      if ( !race.system.senses[key] || (!force && (this.attributes.senses[key] !== null)) ) continue;
      this.attributes.senses[key] = race.system.senses[key];
    }
    this.attributes.senses.special = [this.attributes.senses.special, race.system.senses.special].filterJoin(";");
    if ( force && race.system.senses.units ) this.attributes.senses.units = race.system.senses.units;
    else this.attributes.senses.units ??= race.system.senses.units;
  }

  /* -------------------------------------------- */

  /**
   * Prepare spellcasting DC & modifier.
   * @this {CharacterData|NPCData}
   */
  static prepareSpellcastingAbility() {
    const ability = this.abilities?.[this.attributes.spellcasting];
    this.attributes.spell ??= {};
    this.attributes.spell.abilityLabel = CONFIG.DND5E.abilities[this.attributes.spellcasting]?.label ?? "";
    this.attributes.spell.attack = ability ? ability.attack : this.attributes.prof;
    this.attributes.spell.dc = ability ? ability.dc : 8 + this.attributes.prof;
    this.attributes.spell.mod = ability ? ability.mod : 0;
    Object.defineProperty(this.attributes, "spelldc", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `attributes.spelldc` property on actors has been moved to `attributes.spell.dc`.",
          { since: "DnD5e 4.3", until: "DnD5e 5.0" }
        );
        return this.spell.dc;
      }
    });
    Object.defineProperty(this.attributes, "spellmod", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `attributes.spellmod` property on actors has been moved to `attributes.spell.mod`.",
          { since: "DnD5e 4.3", until: "DnD5e 5.0" }
        );
        return this.spell.mod;
      }
    });
  }
}

const { NumberField: NumberField$g, SchemaField: SchemaField$k } = foundry.data.fields;

/**
 * @typedef {object} AbilityData
 * @property {number} value          Ability score.
 * @property {number} proficient     Proficiency value for saves.
 * @property {number} max            Maximum possible score for the ability.
 * @property {object} bonuses        Bonuses that modify ability checks and saves.
 * @property {string} bonuses.check  Numeric or dice bonus to ability checks.
 * @property {string} bonuses.save   Numeric or dice bonus to ability saving throws.
 * @property {RollConfigData} check    Properties related to ability checks.
 * @property {RollConfigData} save     Properties related to ability saving throws.
 */

/**
 * A template for all actors that share the common template.
 *
 * @property {Object<string, AbilityData>} abilities  Actor's abilities.
 * @mixin
 */
class CommonTemplate extends ActorDataModel.mixin(CurrencyTemplate) {

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      abilities: new MappingField(new SchemaField$k({
        value: new NumberField$g({
          required: true, nullable: false, integer: true, min: 0, initial: 10, label: "DND5E.AbilityScore"
        }),
        proficient: new NumberField$g({
          required: true, integer: true, min: 0, max: 1, initial: 0, label: "DND5E.ProficiencyLevel"
        }),
        max: new NumberField$g({
          required: true, integer: true, nullable: true, min: 0, initial: null, label: "DND5E.AbilityScoreMax"
        }),
        bonuses: new SchemaField$k({
          check: new FormulaField({ required: true, label: "DND5E.AbilityCheckBonus" }),
          save: new FormulaField({ required: true, label: "DND5E.SaveBonus" })
        }, { label: "DND5E.AbilityBonuses" }),
        check: new RollConfigField({ ability: false }),
        save: new RollConfigField({ ability: false })
      }), {
        initialKeys: CONFIG.DND5E.abilities, initialValue: this._initialAbilityValue.bind(this),
        initialKeysOnly: true, label: "DND5E.Abilities"
      })
    });
  }

  /* -------------------------------------------- */

  /**
   * Populate the proper initial value for abilities.
   * @param {string} key       Key for which the initial data will be created.
   * @param {object} initial   The initial skill object created by SkillData.
   * @param {object} existing  Any existing mapping data.
   * @returns {object}         Initial ability object.
   * @private
   */
  static _initialAbilityValue(key, initial, existing) {
    const config = CONFIG.DND5E.abilities[key];
    if ( config ) {
      let defaultValue = config.defaults?.[this._systemType] ?? initial.value;
      if ( typeof defaultValue === "string" ) defaultValue = existing?.[defaultValue]?.value ?? initial.value;
      initial.value = defaultValue;
    }
    return initial;
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    CommonTemplate.#migrateACData(source);
    CommonTemplate.#migrateMovementData(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the actor ac.value to new ac.flat override field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateACData(source) {
    if ( !source.attributes?.ac ) return;
    const ac = source.attributes.ac;

    // If the actor has a numeric ac.value, then their AC has not been migrated to the auto-calculation schema yet.
    if ( Number.isNumeric(ac.value) ) {
      ac.flat = parseInt(ac.value);
      ac.calc = this._systemType === "npc" ? "natural" : "flat";
      return;
    }

    // Migrate ac.base in custom formulas to ac.armor
    if ( (typeof ac.formula === "string") && ac.formula.includes("@attributes.ac.base") ) {
      ac.formula = ac.formula.replaceAll("@attributes.ac.base", "@attributes.ac.armor");
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the actor speed string to movement object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateMovementData(source) {
    const original = source.attributes?.speed?.value ?? source.attributes?.speed;
    if ( (typeof original !== "string") || (source.attributes.movement?.walk !== undefined) ) return;
    source.attributes.movement ??= {};
    const s = original.split(" ");
    if ( s.length > 0 ) source.attributes.movement.walk = Number.isNumeric(s[0]) ? parseInt(s[0]) : 0;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare modifiers and other values for abilities.
   * @param {object} [options={}]
   * @param {object} [options.rollData={}]    Roll data used to calculate bonuses.
   * @param {object} [options.originalSaves]  Original ability data for transformed actors.
   */
  prepareAbilities({ rollData={}, originalSaves }={}) {
    const flags = this.parent.flags.dnd5e ?? {};
    const { prof = 0, ac } = this.attributes ?? {};
    Object.values(this.abilities).forEach(a => a.mod = Math.floor((a.value - 10) / 2));
    const checkBonus = simplifyBonus(this.bonuses?.abilities?.check, rollData);
    const saveBonus = simplifyBonus(this.bonuses?.abilities?.save, rollData);
    const dcBonus = simplifyBonus(this.bonuses?.spell?.dc, rollData);
    for ( const [id, abl] of Object.entries(this.abilities) ) {
      if ( flags.diamondSoul ) abl.proficient = 1;  // Diamond Soul is proficient in all saves

      abl.checkProf = this.calculateAbilityCheckProficiency(0, id);
      const saveBonusAbl = simplifyBonus(abl.bonuses?.save, rollData);

      const cover = id === "dex" ? Math.max(ac?.cover ?? 0, this.parent.coverBonus) : 0;
      abl.saveBonus = saveBonusAbl + saveBonus + cover;

      abl.saveProf = new Proficiency(prof, abl.proficient);
      const checkBonusAbl = simplifyBonus(abl.bonuses?.check, rollData);
      abl.checkBonus = checkBonusAbl + checkBonus;

      abl.save.value = abl.mod + abl.saveBonus;
      if ( Number.isNumeric(abl.saveProf.term) ) abl.save.value += abl.saveProf.flat;
      abl.attack = abl.mod + prof;
      abl.dc = 8 + abl.mod + prof + dcBonus;

      if ( !Number.isFinite(abl.max) ) abl.max = CONFIG.DND5E.maxAbilityScore;

      // If we merged saves when transforming, take the highest bonus here.
      if ( originalSaves && abl.proficient ) abl.save.value = Math.max(abl.save, originalSaves[id].save.value);

      // Deprecations.
      abl.save.toString = function() {
        foundry.utils.logCompatibilityWarning("The 'abilities.<ability>.save' property is now stored in "
          + "'abilities.<ability>.save.value'.", { since: "4.3", until: "4.5" });
        return String(abl.save.value);
      };
      abl.save.toJSON = function() {
        foundry.utils.logCompatibilityWarning("The 'abilities.<ability>.save' property is now stored in "
          + "'abilities.<ability>.save.value'.", { since: "4.3", until: "4.5" });
        return `!${abl.save.value}!`;
      };
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Create the proficiency object for an ability, skill, or tool, taking remarkable athlete and Jack of All Trades
   * into account.
   * @param {number} multiplier  Multiplier stored on the actor.
   * @param {string} ability     Ability associated with this proficiency.
   * @returns {Proficiency}
   */
  calculateAbilityCheckProficiency(multiplier, ability) {
    let roundDown = true;
    if ( multiplier < 1 ) {
      if ( this.parent._isRemarkableAthlete(ability) ) {
        multiplier = .5;
        roundDown = false;
      }
      else if ( this.parent.flags.dnd5e?.jackOfAllTrades ) multiplier = .5;
    }
    return new Proficiency(this.attributes.prof, multiplier, roundDown);
  }
}

const { NumberField: NumberField$f, SchemaField: SchemaField$j } = foundry.data.fields;

/**
 * A template for all actors that are creatures
 *
 * @property {object} bonuses
 * @property {AttackBonusesData} bonuses.mwak        Bonuses to melee weapon attacks.
 * @property {AttackBonusesData} bonuses.rwak        Bonuses to ranged weapon attacks.
 * @property {AttackBonusesData} bonuses.msak        Bonuses to melee spell attacks.
 * @property {AttackBonusesData} bonuses.rsak        Bonuses to ranged spell attacks.
 * @property {object} bonuses.abilities              Bonuses to ability scores.
 * @property {string} bonuses.abilities.check        Numeric or dice bonus to ability checks.
 * @property {string} bonuses.abilities.save         Numeric or dice bonus to ability saves.
 * @property {string} bonuses.abilities.skill        Numeric or dice bonus to skill checks.
 * @property {object} bonuses.spell                  Bonuses to spells.
 * @property {string} bonuses.spell.dc               Numeric bonus to spellcasting DC.
 * @property {Record<string, ToolData>} tools        Actor's tools.
 * @property {Record<string, SkillData>} skills      Actor's skills.
 * @property {Record<string, SpellSlotData>} spells  Actor's spell slots.
 */
class CreatureTemplate extends CommonTemplate {
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      bonuses: new SchemaField$j({
        mwak: makeAttackBonuses(),
        rwak: makeAttackBonuses(),
        msak: makeAttackBonuses(),
        rsak: makeAttackBonuses(),
        abilities: new SchemaField$j({
          check: new FormulaField({ required: true }),
          save: new FormulaField({ required: true }),
          skill: new FormulaField({ required: true })
        }),
        spell: new SchemaField$j({
          dc: new FormulaField({ required: true, deterministic: true })
        })
      }),
      skills: new MappingField(new RollConfigField({
        value: new NumberField$f({
          required: true, nullable: false, min: 0, max: 2, step: 0.5, initial: 0, label: "DND5E.ProficiencyLevel"
        }),
        ability: "dex",
        bonuses: new SchemaField$j({
          check: new FormulaField({ required: true, label: "DND5E.SkillBonusCheck" }),
          passive: new FormulaField({ required: true, label: "DND5E.SkillBonusPassive" })
        }, { label: "DND5E.SkillBonuses" })
      }), {
        initialKeys: CONFIG.DND5E.skills, initialValue: this._initialSkillValue,
        initialKeysOnly: true, label: "DND5E.Skills"
      }),
      tools: new MappingField(new RollConfigField({
        value: new NumberField$f({
          required: true, nullable: false, min: 0, max: 2, step: 0.5, initial: 1, label: "DND5E.ProficiencyLevel"
        }),
        ability: "int",
        bonuses: new SchemaField$j({
          check: new FormulaField({ required: true, label: "DND5E.CheckBonus" })
        }, { label: "DND5E.ToolBonuses" })
      })),
      spells: new MappingField(new SchemaField$j({
        value: new NumberField$f({
          nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SpellProgAvailable"
        }),
        override: new NumberField$f({
          integer: true, min: 0, label: "DND5E.SpellProgOverride"
        })
      }), { initialKeys: this._spellLevels, label: "DND5E.SpellLevels" })
    });
  }

  /* -------------------------------------------- */

  /**
   * Populate the proper initial abilities for the skills.
   * @param {string} key      Key for which the initial data will be created.
   * @param {object} initial  The initial skill object created by SkillData.
   * @returns {object}        Initial skills object with the ability defined.
   * @private
   */
  static _initialSkillValue(key, initial) {
    if ( CONFIG.DND5E.skills[key]?.ability ) initial.ability = CONFIG.DND5E.skills[key].ability;
    return initial;
  }

  /* -------------------------------------------- */

  /**
   * Helper for building the default list of spell levels.
   * @type {string[]}
   * @private
   */
  static get _spellLevels() {
    const levels = Object.keys(CONFIG.DND5E.spellLevels).filter(a => a !== "0").map(l => `spell${l}`);
    return [...levels, "pact"];
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    CreatureTemplate.#migrateSensesData(source);
    CreatureTemplate.#migrateToolData(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the actor traits.senses string to attributes.senses object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSensesData(source) {
    const original = source.traits?.senses;
    if ( (original === undefined) || (typeof original !== "string") ) return;
    source.attributes ??= {};
    source.attributes.senses ??= {};

    // Try to match old senses with the format like "Darkvision 60 ft, Blindsight 30 ft"
    const pattern = /([A-z]+)\s?([0-9]+)\s?([A-z]+)?/;
    let wasMatched = false;

    // Match each comma-separated term
    for ( let s of original.split(",") ) {
      s = s.trim();
      const match = s.match(pattern);
      if ( !match ) continue;
      const type = match[1].toLowerCase();
      if ( (type in CONFIG.DND5E.senses) && !(type in source.attributes.senses) ) {
        source.attributes.senses[type] = Number(match[2]).toNearest(0.5);
        wasMatched = true;
      }
    }

    // If nothing was matched, but there was an old string - put the whole thing in "special"
    if ( !wasMatched && original ) source.attributes.senses.special = original;
  }

  /* -------------------------------------------- */

  /**
   * Migrate traits.toolProf to the tools field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateToolData(source) {
    const original = source.traits?.toolProf;
    if ( !original || foundry.utils.isEmpty(original.value) ) return;
    source.tools ??= {};
    for ( const prof of original.value ) {
      const validProf = (prof in CONFIG.DND5E.toolProficiencies) || (prof in CONFIG.DND5E.tools);
      if ( !validProf || (prof in source.tools) ) continue;
      source.tools[prof] = {
        value: 1,
        ability: "int",
        bonuses: {check: ""}
      };
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare modifiers and other values for skills.
   * @param {object} [options={}]
   * @param {object} [options.rollData={}]     Roll data used to calculate bonuses.
   * @param {object} [options.originalSkills]  Original skills data for transformed actors.
   */
  prepareSkills({ rollData={}, originalSkills }={}) {
    const globalBonuses = this.bonuses.abilities;
    const globalCheckBonus = simplifyBonus(globalBonuses.check, rollData);
    const globalSkillBonus = simplifyBonus(globalBonuses.skill, rollData);
    for ( const [id, skillData] of Object.entries(this.skills) ) {
      this.prepareSkill(id, { skillData, rollData, originalSkills, globalBonuses, globalCheckBonus, globalSkillBonus });
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepares data for a specific skill.
   * @param {string} skillId                     The id of the skill to prepare data for.
   * @param {object} [options]                   Additional options.
   * @param {SkillData} [options.skillData]      The base skill data for this skill.
   *                                             If undefined, `this.system.skill[skillId]` is used.
   * @param {object} [options.rollData]          RollData for this actor, used to evaluate dice terms in bonuses.
   *                                             If undefined, `this.getRollData()` is used.
   * @param {object} [options.originalSkills]    Original skills if actor is polymorphed.
   *                                             If undefined, the skills of the actor identified by
   *                                             `this.flags.dnd5e.originalActor` are used.
   * @param {object} [options.globalBonuses]     Global ability bonuses for this actor.
   *                                             If undefined, `this.system.bonuses.abilities` is used.
   * @param {number} [options.globalCheckBonus]  Global check bonus for this actor.
   *                                             If undefined, `globalBonuses.check` will be evaluated using `rollData`.
   * @param {number} [options.globalSkillBonus]  Global skill bonus for this actor.
   *                                             If undefined, `globalBonuses.skill` will be evaluated using `rollData`.
   * @param {string} [options.ability]           The ability to compute bonuses based on.
   *                                             If undefined, skillData.ability is used.
   * @returns {SkillData}
   */
  prepareSkill(skillId, {
    skillData, rollData, originalSkills, globalBonuses,
    globalCheckBonus, globalSkillBonus, ability
  }={}) {
    const flags = this.parent.flags.dnd5e ?? {};

    skillData ??= foundry.utils.deepClone(this.skills[skillId]);
    rollData ??= this.parent.getRollData();
    originalSkills ??= flags.originalActor ? game.actors?.get(flags.originalActor)?.system?.skills : null;
    globalBonuses ??= this.bonuses.abilities ?? {};
    globalCheckBonus ??= simplifyBonus(globalBonuses.check, rollData);
    globalSkillBonus ??= simplifyBonus(globalBonuses.skill, rollData);
    ability ??= skillData.ability;
    const abilityData = this.abilities[ability];
    skillData.ability = ability;
    const baseBonus = simplifyBonus(skillData.bonuses?.check, rollData);

    // Polymorph Skill Proficiencies
    if ( originalSkills ) skillData.value = Math.max(skillData.value, originalSkills[skillId].value);

    // Compute modifier
    const checkBonusAbl = simplifyBonus(abilityData?.bonuses?.check, rollData);
    skillData.effectValue = skillData.value;
    skillData.bonus = baseBonus + globalCheckBonus + checkBonusAbl + globalSkillBonus;
    skillData.mod = abilityData?.mod ?? 0;
    skillData.prof = this.calculateAbilityCheckProficiency(skillData.value, skillData.ability);
    skillData.value = skillData.proficient = skillData.prof.multiplier;
    skillData.total = skillData.mod + skillData.bonus;
    if ( Number.isNumeric(skillData.prof.term) ) skillData.total += skillData.prof.flat;

    // Compute passive bonus
    const passive = flags.observantFeat && CONFIG.DND5E.characterFlags.observantFeat.skills.includes(skillId) ? 5 : 0;
    const passiveBonus = simplifyBonus(skillData.bonuses?.passive, rollData);
    skillData.passive = 10 + skillData.mod + skillData.bonus + skillData.prof.flat + passive + passiveBonus;

    return skillData;
  }

  /* -------------------------------------------- */

  /**
   * Prepare tool checks. Mutates the values of system.tools.
   * @param {object} [options={}]
   * @param {object} [options.rollData={}]     Roll data used to calculate bonuses.
   */
  prepareTools({ rollData={} }={}) {
    const globalCheckBonus = simplifyBonus(this.bonuses.abilities.check, rollData);
    for ( const tool of Object.values(this.tools) ) {
      const ability = this.abilities[tool.ability];
      const baseBonus = simplifyBonus(tool.bonuses.check, rollData);
      const checkBonusAbl = simplifyBonus(ability?.bonuses?.check, rollData);
      tool.effectValue = tool.value;
      tool.bonus = baseBonus + globalCheckBonus + checkBonusAbl;
      tool.mod = ability?.mod ?? 0;
      tool.prof = this.calculateAbilityCheckProficiency(tool.value, tool.ability);
      tool.total = tool.mod + tool.bonus;
      if ( Number.isNumeric(tool.prof.term) ) tool.total += tool.prof.flat;
      tool.value = tool.prof.multiplier;
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getRollData({ deterministic=false }={}) {
    const data = super.getRollData({ deterministic });
    data.classes = {};
    for ( const [identifier, cls] of Object.entries(this.parent.classes) ) {
      data.classes[identifier] = {...cls.system};
      data.classes[identifier].hitDice = cls.system.hd.denomination; // Backwards compatibility
      if ( cls.subclass ) data.classes[identifier].subclass = cls.subclass.system;
    }
    return data;
  }
}

/**
 * @typedef {RollConfigData} SkillData
 * @property {number} value            Proficiency level creature has in this skill.
 * @property {object} bonuses          Bonuses for this skill.
 * @property {string} bonuses.check    Numeric or dice bonus to skill's check.
 * @property {string} bonuses.passive  Numeric bonus to skill's passive check.
 */

/**
 * @typedef {RollConfigData} ToolData
 * @property {number} value            Proficiency level creature has in this tool.
 * @property {object} bonuses          Bonuses for this tool.
 * @property {string} bonuses.check    Numeric or dice bonus to tool's check.
 */

/**
 * Data on configuration of a specific spell slot.
 *
 * @typedef {object} SpellSlotData
 * @property {number} value     Currently available spell slots.
 * @property {number} override  Number to replace auto-calculated max slots.
 */

/* -------------------------------------------- */

/**
 * Data structure for actor's attack bonuses.
 *
 * @typedef {object} AttackBonusesData
 * @property {string} attack  Numeric or dice bonus to attack rolls.
 * @property {string} damage  Numeric or dice bonus to damage rolls.
 */

/**
 * Produce the schema field for a simple trait.
 * @param {object} schemaOptions  Options passed to the outer schema.
 * @returns {AttackBonusesData}
 */
function makeAttackBonuses(schemaOptions={}) {
  return new SchemaField$j({
    attack: new FormulaField({required: true, label: "DND5E.BonusAttack"}),
    damage: new FormulaField({required: true, label: "DND5E.BonusDamage"})
  }, schemaOptions);
}

const { HTMLField: HTMLField$6, SchemaField: SchemaField$i, StringField: StringField$p } = foundry.data.fields;

/**
 * Shared contents of the details schema between various actor types.
 */
class DetailsField {
  /**
   * Fields shared between characters, NPCs, and vehicles.
   *
   * @type {object}
   * @property {object} biography         Actor's biography data.
   * @property {string} biography.value   Full HTML biography information.
   * @property {string} biography.public  Biography that will be displayed to players with observer privileges.
   */
  static get common() {
    return {
      biography: new SchemaField$i({
        value: new HTMLField$6({label: "DND5E.Biography"}),
        public: new HTMLField$6({label: "DND5E.BiographyPublic"})
      }, {label: "DND5E.Biography"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters and NPCs.
   *
   * @type {object}
   * @property {string} alignment    Creature's alignment.
   * @property {Item5e|string} race  Creature's race item or name.
   */
  static get creature() {
    return {
      alignment: new StringField$p({required: true, label: "DND5E.Alignment"}),
      ideal: new StringField$p({required: true, label: "DND5E.Ideals"}),
      bond: new StringField$p({required: true, label: "DND5E.Bonds"}),
      flaw: new StringField$p({required: true, label: "DND5E.Flaws"}),
      race: new LocalDocumentField(foundry.documents.BaseItem, {
        required: true, fallback: true, label: "DND5E.Species"
      })
    };
  }
}

const { SetField: SetField$e, StringField: StringField$o } = foundry.data.fields;

/**
 * Data structure for a damage actor trait.
 *
 * @typedef {import("./simple-trait.mjs").SimpleTraitData} DamageTraitData
 * @property {Set<string>} bypasses  Keys for physical weapon properties that cause resistances to be bypassed.
 */

/**
 * Field for storing damage resistances, immunities, and vulnerabilities data.
 */
class DamageTraitField extends SimpleTraitField {
  constructor(fields={}, { initialBypasses=[], ...options }={}) {
    super({
      bypasses: new SetField$e(new StringField$o(), {
        label: "DND5E.DamagePhysicalBypass", hint: "DND5E.DamagePhysicalBypassHint", initial: initialBypasses
      })
    }, options);
  }
}

const { NumberField: NumberField$e, SchemaField: SchemaField$h, SetField: SetField$d, StringField: StringField$n } = foundry.data.fields;

/**
 * @import { DamageTraitData } from "./fields/damage-trait.mjs";
 * @import { SimpleTraitData } from "./fields/simple-trait.mjs";
 */

/**
 * Data structure for a damage actor trait.
 *
 * @typedef {object} DamageModificationData
 * @property {Record<string, string>} amount  Damage boost or reduction by damage type.
 * @property {Set<string>} bypasses           Keys for physical properties that cause modification to be bypassed.
 */

/**
 * @typedef {SimpleTraitData} LanguageTraitData
 * @property {Record<string, CommunicationData>} communication  Measured communication ranges (e.g. telepathy).
 */

/**
 * @typedef {object} LanguageCommunicationData
 * @property {string} units  Units used to measure range.
 * @property {number} value  Range to which this ability can be used.
 */

/**
 * Shared contents of the traits schema between various actor types.
 */
class TraitsField {

  /* -------------------------------------------- */

  /**
   * Fields shared between characters, NPCs, and vehicles.
   *
   * @type {object}
   * @property {string} size                Actor's size.
   * @property {DamageTraitData} di         Damage immunities.
   * @property {DamageTraitData} dr         Damage resistances.
   * @property {DamageTraitData} dv         Damage vulnerabilities.
   * @property {DamageModificationData} dm  Damage modification.
   * @property {SimpleTraitData} ci         Condition immunities.
   */
  static get common() {
    return {
      size: new StringField$n({ required: true, initial: "med", label: "DND5E.Size" }),
      di: new DamageTraitField({}, { label: "DND5E.DamImm" }),
      dr: new DamageTraitField({}, { label: "DND5E.DamRes" }),
      dv: new DamageTraitField({}, { label: "DND5E.DamVuln" }),
      dm: new SchemaField$h({
        amount: new MappingField(new FormulaField({ deterministic: true }), { label: "DND5E.DamMod" }),
        bypasses: new SetField$d(new StringField$n(), {
          label: "DND5E.DamagePhysicalBypass", hint: "DND5E.DamagePhysicalBypassHint"
        })
      }),
      ci: new SimpleTraitField({}, { label: "DND5E.ConImm" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Fields shared between characters and NPCs.
   *
   * @type {object}
   * @property {SimpleTraitData} languages  Languages known by this creature.
   */
  static get creature() {
    return {
      languages: new SimpleTraitField({
        communication: new MappingField(new SchemaField$h({
          units: new StringField$n({ initial: () => defaultUnits("length") }),
          value: new NumberField$e({ min: 0 })
        }))
      }, { label: "DND5E.Languages" })
    };
  }

  /* -------------------------------------------- */

  /**
   * Produce the schema field for a simple trait.
   * @param {object} [schemaOptions={}]          Options passed to the outer schema.
   * @param {object} [options={}]
   * @param {string[]} [options.initial={}]      The initial value for the value set.
   * @param {object} [options.extraFields={}]    Additional fields added to schema.
   * @returns {SimpleTraitField}
   */
  static makeSimpleTrait(schemaOptions={}, {initial=[], extraFields={}}={}) {
    foundry.utils.logCompatibilityWarning(
      "The `makeSimpleTrait` method on `TraitsField` has been deprecated and replaced with `SimpleTraitField`.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4" }
    );
    return new SimpleTraitField(extraFields, { initialValue: initial, ...schemaOptions });
  }

  /* -------------------------------------------- */

  /**
   * Produce the schema field for a damage trait.
   * @param {object} [schemaOptions={}]          Options passed to the outer schema.
   * @param {object} [options={}]
   * @param {string[]} [options.initial={}]      The initial value for the value set.
   * @param {object} [options.extraFields={}]    Additional fields added to schema.
   * @returns {DamageTraitField}
   */
  static makeDamageTrait(schemaOptions={}, {initial=[], initialBypasses=[], extraFields={}}={}) {
    foundry.utils.logCompatibilityWarning(
      "The `makeDamageTrait` method on `TraitsField` has been deprecated and replaced with `DamageTraitField`.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4" }
    );
    return new DamageTraitField(extraFields, { initialValue: initial, initialBypasses, ...schemaOptions });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Prepare the language labels.
   * @this {CharacterData|NPCData}
   */
  static prepareLanguages() {
    const languages = this.traits.languages;
    const labels = languages.labels = { languages: [], ranged: [] };

    if ( languages.value.has("ALL") ) labels.languages.push(game.i18n.localize("DND5E.Language.All"));
    else {
      const processCategory = (key, data, group) => {
        // If key is within languages, don't bother with children
        if ( languages.value.has(key) ) (group?.children ?? labels.languages).push(data.label ?? data);

        // Display children as part of this group (e.g. "Primordial (Ignan)")
        else if ( data.children ) {
          const topLevel = group === undefined;
          group ??= { label: data.label, children: [] };
          Object.entries(data.children).forEach(([k, d]) => processCategory(k, d, group));
          if ( topLevel && group.children.length ) labels.languages.push(
            `${data.label} (${game.i18n.getListFormatter({ type: "unit" }).format(group.children)})`
          );
        }
      };

      for ( const [key, data] of Object.entries(CONFIG.DND5E.languages) ) {
        if ( data.children ) Object.entries(data.children).forEach(([k, d]) => processCategory(k, d));
        else processCategory(key, data);
      }
    }

    labels.languages.push(...splitSemicolons(languages.custom));

    for ( const [key, { label }] of Object.entries(CONFIG.DND5E.communicationTypes) ) {
      const data = languages.communication?.[key];
      if ( !data?.value ) continue;
      labels.ranged.push(`${label} ${formatLength(data.value, data.units)}`);
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare condition immunities & petrified condition.
   * @this {CharacterData|NPCData|VehicleData}
   */
  static prepareResistImmune() {
    // Apply condition immunities
    for ( const condition of this.traits.ci.value ) this.parent.statuses.delete(condition);

    // Apply petrified condition
    if ( this.parent.hasConditionEffect("petrification") ) {
      this.traits.dr.custom = game.i18n.localize("DND5E.DamageAll");
      Object.keys(CONFIG.DND5E.damageTypes).forEach(type => this.traits.dr.value.add(type));
      this.traits.dr.bypasses.clear();
      this.traits.di.value.add("poison");
      this.traits.ci.value.add("poisoned");
      this.traits.ci.value.add("diseased");
    }
  }
}

const {
  ArrayField: ArrayField$b, BooleanField: BooleanField$a, HTMLField: HTMLField$5, IntegerSortField, NumberField: NumberField$d, SchemaField: SchemaField$g, SetField: SetField$c, StringField: StringField$m
} = foundry.data.fields;

/**
 * @import { SimpleTraitData } from "./fields/simple-trait.mjs";
 */

/**
 * @typedef {object} ActorFavorites5e
 * @property {"activity"|"effect"|"item"|"skill"|"slots"|"tool"} type  The favorite type.
 * @property {string} id                                    The Document UUID, skill or tool identifier, or spell slot
 *                                                          level identifier.
 * @property {number} [sort]                                The sort value.
 */

/**
 * System data definition for Characters.
 *
 * @property {object} attributes
 * @property {object} attributes.hp
 * @property {number} attributes.hp.value                 Current hit points.
 * @property {number} attributes.hp.max                   Override for maximum HP.
 * @property {number} attributes.hp.temp                  Temporary HP applied on top of value.
 * @property {number} attributes.hp.tempmax               Temporary change to the maximum HP.
 * @property {object} attributes.hp.bonuses
 * @property {string} attributes.hp.bonuses.level         Bonus formula applied for each class level.
 * @property {string} attributes.hp.bonuses.overall       Bonus formula applied to total HP.
 * @property {object} attributes.death
 * @property {object} attributes.death.bonuses
 * @property {string} attributes.death.bonuses.save       Numeric or dice bonus to death saving throws.
 * @property {number} attributes.death.success            Number of successful death saves.
 * @property {number} attributes.death.failure            Number of failed death saves.
 * @property {number} attributes.exhaustion               Number of levels of exhaustion.
 * @property {number} attributes.inspiration              Does this character have inspiration?
 * @property {object} bastion
 * @property {string} bastion.name                        The name of the character's bastion.
 * @property {string} bastion.description                 Additional description and details for the character's
 *                                                        bastion.
 * @property {object} details
 * @property {Item5e|string} details.background           Character's background item or name.
 * @property {string} details.originalClass               ID of first class taken by character.
 * @property {object} details.xp                          Experience points gained.
 * @property {number} details.xp.value                    Total experience points earned.
 * @property {string} details.appearance                  Description of character's appearance.
 * @property {string} details.trait                       Character's personality traits.
 * @property {string} details.ideal                       Character's ideals.
 * @property {string} details.bond                        Character's bonds.
 * @property {string} details.flaw                        Character's flaws.
 * @property {object} traits
 * @property {SimpleTraitData} traits.weaponProf             Character's weapon proficiencies.
 * @property {object} traits.weaponProf.mastery
 * @property {Set<string>} traits.weaponProf.mastery.value   Weapon masteries.
 * @property {Set<string>} traits.weaponProf.mastery.bonus   Extra mastery properties that can be chosen when making an
 *                                                           attack with a weapon that has mastery.
 * @property {SimpleTraitData} traits.armorProf              Character's armor proficiencies.
 * @property {object} resources
 * @property {ResourceData} resources.primary             Resource number one.
 * @property {ResourceData} resources.secondary           Resource number two.
 * @property {ResourceData} resources.tertiary            Resource number three.
 * @property {ActorFavorites5e[]} favorites               The character's favorites.
 */
class CharacterData extends CreatureTemplate {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.BONUSES"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    supportsAdvancement: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _systemType = "character";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      attributes: new SchemaField$g({
        ...AttributesFields.common,
        ...AttributesFields.creature,
        hp: new SchemaField$g({
          value: new NumberField$d({
            nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.HitPointsCurrent"
          }),
          max: new NumberField$d({
            nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsOverride",
            hint: "DND5E.HitPointsOverrideHint"
          }),
          temp: new NumberField$d({ integer: true, initial: 0, min: 0, label: "DND5E.HitPointsTemp" }),
          tempmax: new NumberField$d({
            integer: true, initial: 0, label: "DND5E.HitPointsTempMax", hint: "DND5E.HitPointsTempMaxHint"
          }),
          bonuses: new SchemaField$g({
            level: new FormulaField({ deterministic: true, label: "DND5E.HitPointsBonusLevel" }),
            overall: new FormulaField({ deterministic: true, label: "DND5E.HitPointsBonusOverall" })
          })
        }, { label: "DND5E.HitPoints" }),
        death: new RollConfigField({
          ability: false,
          success: new NumberField$d({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveSuccesses"
          }),
          failure: new NumberField$d({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveFailures"
          }),
          bonuses: new SchemaField$g({
            save: new FormulaField({ required: true, label: "DND5E.DeathSaveBonus" })
          })
        }, { label: "DND5E.DeathSave" }),
        inspiration: new BooleanField$a({ required: true, label: "DND5E.Inspiration" })
      }, { label: "DND5E.Attributes" }),
      bastion: new SchemaField$g({
        name: new StringField$m({ required: true }),
        description: new HTMLField$5()
      }),
      details: new SchemaField$g({
        ...DetailsField.common,
        ...DetailsField.creature,
        background: new LocalDocumentField(foundry.documents.BaseItem, {
          required: true, fallback: true, label: "DND5E.Background"
        }),
        originalClass: new StringField$m({ required: true, label: "DND5E.ClassOriginal" }),
        xp: new SchemaField$g({
          value: new NumberField$d({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.ExperiencePoints.Current"
          })
        }, { label: "DND5E.ExperiencePoints.Label" }),
        appearance: new StringField$m({ required: true, label: "DND5E.Appearance" }),
        trait: new StringField$m({ required: true, label: "DND5E.PersonalityTraits" }),
        gender: new StringField$m({ label: "DND5E.Gender" }),
        eyes: new StringField$m({ label: "DND5E.Eyes" }),
        height: new StringField$m({ label: "DND5E.Height" }),
        faith: new StringField$m({ label: "DND5E.Faith" }),
        hair: new StringField$m({ label: "DND5E.Hair" }),
        skin: new StringField$m({ label: "DND5E.Skin" }),
        age: new StringField$m({ label: "DND5E.Age" }),
        weight: new StringField$m({ label: "DND5E.Weight" })
      }, { label: "DND5E.Details" }),
      traits: new SchemaField$g({
        ...TraitsField.common,
        ...TraitsField.creature,
        weaponProf: new SimpleTraitField({
          mastery: new SchemaField$g({
            value: new SetField$c(new StringField$m()),
            bonus: new SetField$c(new StringField$m())
          })
        }, { label: "DND5E.TraitWeaponProf" }),
        armorProf: new SimpleTraitField({}, { label: "DND5E.TraitArmorProf" })
      }, { label: "DND5E.Traits" }),
      resources: new SchemaField$g({
        primary: makeResourceField({ label: "DND5E.ResourcePrimary" }),
        secondary: makeResourceField({ label: "DND5E.ResourceSecondary" }),
        tertiary: makeResourceField({ label: "DND5E.ResourceTertiary" })
      }, { label: "DND5E.Resources" }),
      favorites: new ArrayField$b(new SchemaField$g({
        type: new StringField$m({ required: true, blank: false }),
        id: new StringField$m({ required: true, blank: false }),
        sort: new IntegerSortField()
      }), { label: "DND5E.Favorites" })
    });
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    AttributesFields._migrateInitiative(source.attributes);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.attributes.hd = new HitDice(this.parent);
    this.details.level = 0;
    this.attributes.attunement.value = 0;

    for ( const item of this.parent.items ) {
      if ( item.system.attuned ) this.attributes.attunement.value += 1;
      if ( item.type === "class" ) this.details.level += item.system.levels;
    }

    // Character proficiency bonus
    this.attributes.prof = Proficiency.calculateMod(this.details.level);

    // Experience required for next level
    const { xp, level } = this.details;
    xp.max = level >= CONFIG.DND5E.maxLevel ? Infinity : this.parent.getLevelExp(level || 1);
    xp.min = level ? this.parent.getLevelExp(level - 1) : 0;
    if ( Number.isFinite(xp.max) ) {
      const required = xp.max - xp.min;
      const pct = Math.round((xp.value - xp.min) * 100 / required);
      xp.pct = Math.clamp(pct, 0, 100);
    } else if ( game.settings.get("dnd5e", "levelingMode") === "xpBoons" ) {
      const overflow = xp.value - this.parent.getLevelExp(CONFIG.DND5E.maxLevel);
      xp.boonsEarned = Math.max(0, Math.floor(overflow / CONFIG.DND5E.epicBoonInterval));
      const progress = overflow - (CONFIG.DND5E.epicBoonInterval * xp.boonsEarned);
      xp.pct = Math.clamp(Math.round((progress / CONFIG.DND5E.epicBoonInterval) * 100), 0, 100);
    } else {
      xp.pct = 100;
    }

    AttributesFields.prepareBaseArmorClass.call(this);
    AttributesFields.prepareBaseEncumbrance.call(this);
  }

  /* -------------------------------------------- */

  /**
   * Prepare movement & senses values derived from race item.
   */
  prepareEmbeddedData() {
    super.prepareEmbeddedData();
    if ( this.details.race instanceof Item ) {
      AttributesFields.prepareRace.call(this, this.details.race);
      this.details.type = this.details.race.system.type;
    } else {
      this.details.type = new CreatureTypeField({ swarm: false }).initialize({ value: "humanoid" }, this);
    }
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) this.attributes.movement[key] ??= 0;
    for ( const key of Object.keys(CONFIG.DND5E.senses) ) this.attributes.senses[key] ??= 0;
    this.attributes.movement.units ??= defaultUnits("length");
    this.attributes.senses.units ??= defaultUnits("length");
  }

  /* -------------------------------------------- */

  /**
   * Prepare remaining character data.
   */
  prepareDerivedData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const { originalSaves, originalSkills } = this.parent.getOriginalStats();

    this.prepareAbilities({ rollData, originalSaves });
    this.prepareSkills({ rollData, originalSkills });
    this.prepareTools({ rollData });
    AttributesFields.prepareArmorClass.call(this, rollData);
    AttributesFields.prepareConcentration.call(this, rollData);
    AttributesFields.prepareEncumbrance.call(this, rollData);
    AttributesFields.prepareExhaustionLevel.call(this);
    AttributesFields.prepareInitiative.call(this, rollData);
    AttributesFields.prepareMovement.call(this);
    AttributesFields.prepareConcentration.call(this, rollData);
    AttributesFields.prepareSpellcastingAbility.call(this);
    TraitsField.prepareLanguages.call(this);
    TraitsField.prepareResistImmune.call(this);

    // Hit Points
    const hpOptions = {};
    if ( this.attributes.hp.max === null ) {
      hpOptions.advancement = Object.values(this.parent.classes)
        .map(c => c.advancement.byType.HitPoints?.[0]).filter(a => a);
      hpOptions.bonus = (simplifyBonus(this.attributes.hp.bonuses.level, rollData) * this.details.level)
        + simplifyBonus(this.attributes.hp.bonuses.overall, rollData);
      hpOptions.mod = this.abilities[CONFIG.DND5E.defaultAbilities.hitPoints ?? "con"]?.mod ?? 0;
    }
    AttributesFields.prepareHitPoints.call(this, this.attributes.hp, hpOptions);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Level used to determine cantrip scaling.
   * @param {Item5e} spell  Spell for which to fetch the cantrip level.
   * @returns {number}
   */
  cantripLevel(spell) {
    return this.details.level;
  }

  /* -------------------------------------------- */

  /**
   * Checks whether the item with the given relative UUID has been favorited
   * @param {string} favoriteId  The relative UUID of the item to check.
   * @returns {boolean}
   */
  hasFavorite(favoriteId) {
    return !!this.favorites.find(f => f.id === favoriteId);
  }

  /* -------------------------------------------- */

  /**
   * Add a favorite item to this actor.
   * If the given item is already favorite, this method has no effect.
   * @param {ActorFavorites5e} favorite  The favorite to add.
   * @returns {Promise<Actor5e>}
   * @throws If the item intended to be favorited does not belong to this actor.
   */
  addFavorite(favorite) {
    if ( this.hasFavorite(favorite.id) ) return Promise.resolve(this.parent);

    if ( favorite.id.startsWith(".") && fromUuidSync(favorite.id, { relative: this.parent }) === null ) {
      // Assume that an ID starting with a "." is a relative ID.
      throw new Error(`The item with id ${favorite.id} is not owned by actor ${this.parent.id}`);
    }

    let maxSort = 0;
    const favorites = this.favorites.map(f => {
      if ( f.sort > maxSort ) maxSort = f.sort;
      return { ...f };
    });
    favorites.push({ ...favorite, sort: maxSort + CONST.SORT_INTEGER_DENSITY });
    return this.parent.update({ "system.favorites": favorites });
  }

  /* -------------------------------------------- */

  /**
   * Removes the favorite with the given relative UUID or resource ID
   * @param {string} favoriteId  The relative UUID or resource ID of the favorite to remove.
   * @returns {Promise<Actor5e>}
   */
  removeFavorite(favoriteId) {
    if ( favoriteId.startsWith("resources.") ) return this.parent.update({ [`system.${favoriteId}.max`]: 0 });
    const favorites = this.favorites.filter(f => f.id !== favoriteId);
    return this.parent.update({ "system.favorites": favorites });
  }
}

/* -------------------------------------------- */

/**
 * Data structure for character's resources.
 *
 * @typedef {object} ResourceData
 * @property {number} value  Available uses of this resource.
 * @property {number} max    Maximum allowed uses of this resource.
 * @property {boolean} sr    Does this resource recover on a short rest?
 * @property {boolean} lr    Does this resource recover on a long rest?
 * @property {string} label  Displayed name.
 */

/**
 * Produce the schema field for a simple trait.
 * @param {object} schemaOptions  Options passed to the outer schema.
 * @returns {ResourceData}
 */
function makeResourceField(schemaOptions={}) {
  return new SchemaField$g({
    value: new NumberField$d({required: true, integer: true, initial: 0, labels: "DND5E.ResourceValue"}),
    max: new NumberField$d({required: true, integer: true, initial: 0, labels: "DND5E.ResourceMax"}),
    sr: new BooleanField$a({required: true, labels: "DND5E.REST.Short.Recovery"}),
    lr: new BooleanField$a({required: true, labels: "DND5E.REST.Long.Recovery"}),
    label: new StringField$m({required: true, labels: "DND5E.ResourceLabel"})
  }, schemaOptions);
}

/**
 * Sheet config with extra options.
 */
class SheetConfig5e extends DocumentSheetConfig {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      template: "systems/dnd5e/templates/shared/sheet-config.hbs"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData(options) {
    const context = super.getData(options);
    context.CONFIG = CONFIG.DND5E;
    return context;
  }

  /* -------------------------------------------- */

  async _updateObject(event, formData) {
    super._updateObject(event, formData);
    delete formData.sheetClass;
    delete formData.defaultClass;
    this.object.update(formData);

    if ( "flags.dnd5e.theme" in formData ) {
      const sheet = this.object.sheet.element?.[0];
      if ( sheet ) setTheme(sheet, formData["flags.dnd5e.theme"]);
    }
  }
}

/**
 * A specialized subclass of Tabs that handles tabs which exist outside an Application's inner HTML.
 * @extends {Tabs}
 */
class Tabs5e extends Tabs {
  /** @override */
  bind(html) {
    if ( !this._nav ) {
      this._nav = html.closest(".app")?.querySelector(this._navSelector);
      this._nav?.addEventListener("click", this._onClickNav.bind(this));
    }
    if ( !this._nav ) return;
    if ( !this._contentSelector ) this._content = null;
    else if ( html.matches(this._contentSelector) ) this._content = html;
    else this._content = html.querySelector(this._contentSelector);
    this.activate(this.active);
  }
}

/**
 * Adds common V2 sheet functionality.
 * @param {typeof DocumentSheet} Base  The base class being mixed.
 * @returns {typeof DocumentSheetV2}
 */
function DocumentSheetV2Mixin(Base) {
  return class DocumentSheetV2 extends DragDropApplicationMixin(Base) {
    /**
     * @typedef {object} SheetTabDescriptor5e
     * @property {string} tab                       The tab key.
     * @property {string} label                     The tab label's localization key.
     * @property {string} [icon]                    A font-awesome icon.
     * @property {string} [svg]                     An SVG icon.
     * @property {SheetTabCondition5e} [condition]  A predicate to check before rendering the tab.
     */

    /**
     * @callback SheetTabCondition5e
     * @param {Document} doc  The Document instance.
     * @returns {boolean}     Whether to render the tab.
     */

    /**
     * Sheet tabs.
     * @type {SheetTabDescriptor5e[]}
     */
    static TABS = [];

    /**
     * Available sheet modes.
     * @enum {number}
     */
    static MODES = {
      PLAY: 1,
      EDIT: 2
    };

    /**
     * The mode the sheet is currently in.
     * @type {ActorSheetV2.MODES|null}
     * @protected
     */
    _mode = null;

    /* -------------------------------------------- */

    /** @inheritDoc */
    static _customElements = super._customElements.concat(["dnd5e-checkbox", "proficiency-cycle", "slide-toggle"]);

    /** @inheritDoc */
    static _customElements = super._customElements.concat(["dnd5e-checkbox"]);

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /** @inheritDoc */
    async _render(force, { mode, ...options }={}) {
      if ( (mode === undefined) && (options.renderContext === "createItem") ) mode = this.constructor.MODES.EDIT;
      this._mode = mode ?? this._mode ?? this.constructor.MODES.PLAY;
      if ( this.rendered ) {
        const toggle = this.element[0].querySelector(".window-header .mode-slider");
        toggle.checked = this._mode === this.constructor.MODES.EDIT;
      }
      return super._render(force, options);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _renderOuter() {
      const html = await super._renderOuter();
      const header = html[0].querySelector(".window-header");

      // Adjust header buttons.
      header.querySelectorAll(".header-button").forEach(btn => {
        const label = btn.querySelector(":scope > i").nextSibling;
        btn.dataset.tooltip = label.textContent;
        btn.setAttribute("aria-label", label.textContent);
        btn.addEventListener("dblclick", event => event.stopPropagation());
        label.remove();
      });

      if ( !game.user.isGM && this.document.limited ) {
        html[0].classList.add("limited");
        return html;
      }

      // Add edit <-> play slide toggle.
      if ( this.isEditable ) {
        const toggle = document.createElement("slide-toggle");
        toggle.checked = this._mode === this.constructor.MODES.EDIT;
        toggle.classList.add("mode-slider");
        toggle.dataset.tooltip = "DND5E.SheetModeEdit";
        toggle.setAttribute("aria-label", game.i18n.localize("DND5E.SheetModeEdit"));
        toggle.addEventListener("change", this._onChangeSheetMode.bind(this));
        toggle.addEventListener("dblclick", event => event.stopPropagation());
        header.insertAdjacentElement("afterbegin", toggle);
      }

      // Document UUID link.
      const firstButton = header.querySelector(".header-button");
      const idLink = header.querySelector(".document-id-link");
      if ( idLink ) {
        firstButton?.insertAdjacentElement("beforebegin", idLink);
        idLink.classList.add("pseudo-header-button");
        idLink.dataset.tooltipDirection = "DOWN";
      }

      return html;
    }

    /* -------------------------------------------- */

    /**
     * Render source information in the Document's title bar.
     * @param {jQuery} html  The outer frame HTML.
     * @protected
     */
    _renderSourceOuter([html]) {
      const elements = document.createElement("div");
      elements.classList.add("header-elements");
      elements.innerHTML = `
        <div class="source-book">
          <a class="config-button" data-action="source" data-tooltip="DND5E.SOURCE.Action.Configure"
             aria-label="${game.i18n.localize("DND5E.SOURCE.Action.Configure")}">
            <i class="fas fa-cog"></i>
          </a>
          <span></span>
        </div>
      `;
      html.querySelector(".window-title")?.insertAdjacentElement("afterend", elements);
      elements.querySelector(".config-button").addEventListener("click", this._onConfigMenu.bind(this));
    }

    /* -------------------------------------------- */

    /**
     * Update the source information when re-rendering the sheet.
     * @protected
     */
    _renderSource() {
      const [elements] = this.element.find(".header-elements");
      const source = this.document?.system.source;
      if ( !elements || !source ) return;
      const editable = this.isEditable && (this._mode === this.constructor.MODES.EDIT);
      elements.querySelector(".config-button")?.toggleAttribute("hidden", !editable);
      elements.querySelector(".source-book > span").innerText = editable
        ? (source.label || game.i18n.localize("DND5E.SOURCE.FIELDS.source.label"))
        : source.label;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async getData(options) {
      const context = await super.getData(options);
      context.editable = this.isEditable && (this._mode === this.constructor.MODES.EDIT);
      context.cssClass = context.editable ? "editable" : this.isEditable ? "interactable" : "locked";
      return context;
    }

    /* -------------------------------------------- */
    /*  Event Listeners & Handlers                  */
    /* -------------------------------------------- */

    /** @inheritDoc */
    activateListeners(html) {
      super.activateListeners(html);
      html.find("[data-toggle-description]").on("click", this._onToggleDescription.bind(this));
      this.form.querySelectorAll(".item-tooltip").forEach(this._applyItemTooltips.bind(this));

      if ( this.isEditable ) {
        this.form.querySelectorAll("multi-select .tag").forEach(tag => {
          tag.classList.add("remove");
          tag.querySelector(":scope > span")?.classList.add("remove");
        });
        html.find(".create-child").on("click", this._onCreateChild.bind(this));
      }
    }

    /* -------------------------------------------- */

    /**
     * Initialize item tooltips on an element.
     * @param {HTMLElement} element  The tooltipped element.
     * @protected
     */
    _applyItemTooltips(element) {
      if ( "tooltip" in element.dataset ) return;
      const target = element.closest("[data-item-id], [data-effect-id], [data-uuid]");
      let uuid = target.dataset.uuid;
      if ( !uuid && target.dataset.itemId ) {
        const item = this.actor?.items.get(target.dataset.itemId);
        uuid = item?.uuid;
      } else if ( !uuid && target.dataset.effectId ) {
        const { effectId, parentId } = target.dataset;
        const collection = parentId ? this.actor?.items.get(parentId).effects : this.actor?.effects;
        uuid = collection?.get(effectId)?.uuid;
      }
      if ( !uuid ) return;
      element.dataset.tooltip = `
        <section class="loading" data-uuid="${uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>
      `;
      element.dataset.tooltipClass = "dnd5e2 dnd5e-tooltip item-tooltip";
      element.dataset.tooltipDirection ??= "LEFT";
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _disableFields(form) {
      super._disableFields(form);
      form.querySelectorAll(".interface-only").forEach(input => input.disabled = false);
      form.querySelectorAll("dnd5e-checkbox:not(.interface-only)").forEach(input => input.disabled = true);
    }

    /* -------------------------------------------- */

    /**
     * Handle the user toggling the sheet mode.
     * @param {Event} event  The triggering event.
     * @protected
     */
    async _onChangeSheetMode(event) {
      const { MODES } = this.constructor;
      const toggle = event.currentTarget;
      const label = game.i18n.localize(`DND5E.SheetMode${toggle.checked ? "Play" : "Edit"}`);
      toggle.dataset.tooltip = label;
      toggle.setAttribute("aria-label", label);
      this._mode = toggle.checked ? MODES.EDIT : MODES.PLAY;
      await this.submit();
      this.render();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onChangeTab(event, tabs, active) {
      super._onChangeTab(event, tabs, active);
      this.form.className = this.form.className.replace(/tab-\w+/g, "");
      this.form.classList.add(`tab-${active}`);
    }

    /* -------------------------------------------- */

    /**
     * Handle creating a new embedded child.
     * @returns {any}
     * @protected
     * @abstract
     */
    _onCreateChild() {}

    /* -------------------------------------------- */

    /**
     * Handle toggling an Item's description.
     * @param {PointerEvent} event  The triggering event.
     * @protected
     */
    async _onToggleDescription(event) {
      const target = event.currentTarget;
      const icon = target.querySelector(":scope > i");
      const row = target.closest("[data-uuid]");
      const summary = row.querySelector(":scope > .item-description > .wrapper");
      const { uuid } = row.dataset;
      const item = await fromUuid(uuid);
      if ( !item ) return;

      const expanded = this._expanded.has(item.id);
      if ( expanded ) {
        summary.parentElement.addEventListener("transitionend", () => {
          if ( row.classList.contains("collapsed") ) summary.querySelector(".item-summary")?.remove();
        }, { once: true });
        this._expanded.delete(item.id);
      } else {
        const context = await item.getChatData({ secrets: item.isOwner });
        const content = await renderTemplate("systems/dnd5e/templates/items/parts/item-summary.hbs", context);
        summary.querySelectorAll(".item-summary").forEach(el => el.remove());
        summary.insertAdjacentHTML("beforeend", content);
        await new Promise(resolve => requestAnimationFrame(resolve));
        this._expanded.add(item.id);
      }

      row.classList.toggle("collapsed", expanded);
      icon.classList.toggle("fa-compress", !expanded);
      icon.classList.toggle("fa-expand", expanded);
    }

    /* -------------------------------------------- */
    /*  Drag & Drop                                 */
    /* -------------------------------------------- */

    /** @override */
    _allowedDropBehaviors(event, data) {
      if ( !data.uuid ) return new Set(["copy", "link"]);
      const allowed = new Set(["copy", "move", "link"]);
      const s = foundry.utils.parseUuid(data.uuid);
      const t = foundry.utils.parseUuid(this.document.uuid);
      const sCompendium = s.collection instanceof CompendiumCollection;
      const tCompendium = t.collection instanceof CompendiumCollection;

      // If either source or target are within a compendium, but not inside the same compendium, move not allowed
      if ( (sCompendium || tCompendium) && (s.collection !== t.collection) ) allowed.delete("move");

      return allowed;
    }

    /* -------------------------------------------- */

    /** @override */
    _defaultDropBehavior(event, data) {
      if ( !data.uuid ) return "copy";
      const d = foundry.utils.parseUuid(data.uuid);
      const t = foundry.utils.parseUuid(this.document.uuid);
      const base = d.embedded?.length ? "document" : "primary";
      return (d.collection === t.collection) && (d[`${base}Id`] === t[`${base}Id`])
        && (d[`${base}Type`] === t[`${base}Type`]) ? "move" : "copy";
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _onDragStart(event) {
      await super._onDragStart(event);
      if ( !this.document.isOwner
        || this.document[game.release.generation < 13 ? "compendium" : "collection"]?.locked ) {
        event.dataTransfer.effectAllowed = "copyLink";
      }
    }
  };
}

/**
 * Dialog to select which new advancements should be added to an item.
 */
class AdvancementMigrationDialog extends Dialog {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e", "advancement-migration", "dialog"],
      jQuery: false,
      width: 500
    });
  }

  /* -------------------------------------------- */

  /**
   * A helper constructor function which displays the migration dialog.
   * @param {Item5e} item                    Item to which the advancements are being added.
   * @param {Advancement[]} advancements     New advancements that should be displayed in the prompt.
   * @returns {Promise<Advancement[]|null>}  Resolves with the advancements that should be added, if any.
   */
  static createDialog(item, advancements) {
    const advancementContext = advancements.map(a => ({
      id: a.id, icon: a.icon, title: a.title,
      summary: a.levels.length === 1 ? a.summaryForLevel(a.levels[0]) : ""
    }));
    return new Promise(async (resolve, reject) => {
      const dialog = new this({
        title: `${game.i18n.localize("DND5E.AdvancementMigrationTitle")}: ${item.name}`,
        content: await renderTemplate(
          "systems/dnd5e/templates/advancement/advancement-migration-dialog.hbs",
          { item, advancements: advancementContext }
        ),
        buttons: {
          continue: {
            icon: '<i class="fas fa-check"></i>',
            label: game.i18n.localize("DND5E.AdvancementMigrationConfirm"),
            callback: html => resolve(advancements.filter(a => html.querySelector(`[name="${a.id}"]`)?.checked))
          },
          cancel: {
            icon: '<i class="fas fa-times"></i>',
            label: game.i18n.localize("Cancel"),
            callback: html => reject(null)
          }
        },
        default: "continue",
        close: () => reject(null)
      });
      dialog.render(true);
    });
  }

}

/**
 * @typedef {object} AccordionConfiguration
 * @property {string} headingSelector    The CSS selector that identifies accordion headers in the given markup.
 * @property {string} contentSelector    The CSS selector that identifies accordion content in the given markup. This
 *                                       can match content within the heading element, or sibling to the heading
 *                                       element, with priority given to the former.
 * @property {boolean} [collapseOthers]  Automatically collapses the other headings in this group when one heading is
 *                                       clicked.
 */

/**
 * A class responsible for augmenting markup with an accordion effect.
 * @param {AccordionConfiguration} config  Configuration options.
 */
class Accordion {
  constructor(config) {
    config.contentSelector = `${config.contentSelector}:not(.accordion-content)`;
    this.#config = config;
  }

  /**
   * Configuration options.
   * @type {AccordionConfiguration}
   */
  #config;

  /**
   * A mapping of heading elements to content elements.
   * @type {Map<HTMLElement, HTMLElement>}
   */
  #sections = new Map();

  /**
   * A mapping of heading elements to any ongoing transition effect functions.
   * @type {Map<HTMLElement, Function>}
   */
  #ongoing = new Map();

  /**
   * Record the state of collapsed sections.
   * @type {boolean[]}
   */
  #collapsed;

  /* -------------------------------------------- */

  /**
   * Augment the given markup with an accordion effect.
   * @param {HTMLElement} root  The root HTML node.
   */
  bind(root) {
    const firstBind = this.#sections.size < 1;
    if ( firstBind ) this.#collapsed = [];
    this.#sections = new Map();
    this.#ongoing = new Map();
    const { headingSelector, contentSelector } = this.#config;
    let collapsedIndex = 0;
    for ( const heading of root.querySelectorAll(headingSelector) ) {
      const content = heading.querySelector(contentSelector) ?? heading.parentElement.querySelector(contentSelector);
      if ( !content ) continue;
      const wrapper = document.createElement("div");
      wrapper.classList.add("accordion");
      heading.before(wrapper);
      wrapper.append(heading, content);
      this.#sections.set(heading, content);
      content._fullHeight = content.getBoundingClientRect().height;
      if ( firstBind ) this.#collapsed.push(this.#collapsed.length > 0);
      else if ( this.#collapsed[collapsedIndex] ) wrapper.classList.add("collapsed");
      heading.classList.add("accordion-heading");
      content.classList.add("accordion-content");
      heading.addEventListener("click", this._onClickHeading.bind(this));
      collapsedIndex++;
    }
    requestAnimationFrame(() => this._restoreCollapsedState());
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking an accordion heading.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickHeading(event) {
    if ( event.target.closest("a") ) return;
    const heading = event.currentTarget;
    const content = this.#sections.get(heading);
    if ( !content ) return;
    event.preventDefault();
    const collapsed = heading.parentElement.classList.contains("collapsed");
    if ( collapsed ) this._onExpandSection(heading, content);
    else this._onCollapseSection(heading, content);
  }

  /* -------------------------------------------- */

  /**
   * Handle expanding a section.
   * @param {HTMLElement} heading             The section heading.
   * @param {HTMLElement} content             The section content.
   * @param {object} [options]
   * @param {boolean} [options.animate=true]  Whether to animate the expand effect.
   * @protected
   */
  _onExpandSection(heading, content, { animate=true }={}) {
    this.#cancelOngoing(heading);

    if ( this.#config.collapseOthers ) {
      for ( const [otherHeading, otherContent] of this.#sections.entries() ) {
        if ( (heading !== otherHeading) && !otherHeading.parentElement.classList.contains("collapsed") ) {
          this._onCollapseSection(otherHeading, otherContent, { animate });
        }
      }
    }

    heading.parentElement.classList.remove("collapsed");
    if ( animate ) content.style.height = "0";
    else {
      content.style.height = `${content._fullHeight}px`;
      return;
    }
    requestAnimationFrame(() => {
      const onEnd = this._onEnd.bind(this, heading, content);
      this.#ongoing.set(heading, onEnd);
      content.addEventListener("transitionend", onEnd, { once: true });
      content.style.height = `${content._fullHeight}px`;
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle collapsing a section.
   * @param {HTMLElement} heading             The section heading.
   * @param {HTMLElement} content             The section content.
   * @param {object} [options]
   * @param {boolean} [options.animate=true]  Whether to animate the collapse effect.
   * @protected
   */
  _onCollapseSection(heading, content, { animate=true }={}) {
    this.#cancelOngoing(heading);
    const { height } = content.getBoundingClientRect();
    heading.parentElement.classList.add("collapsed");
    content._fullHeight = height || content._fullHeight;
    if ( animate ) content.style.height = `${height}px`;
    else {
      content.style.height = "0";
      return;
    }
    requestAnimationFrame(() => {
      const onEnd = this._onEnd.bind(this, heading, content);
      this.#ongoing.set(heading, onEnd);
      content.addEventListener("transitionend", onEnd, { once: true });
      content.style.height = "0";
    });
  }

  /* -------------------------------------------- */

  /**
   * A function to invoke when the height transition has ended.
   * @param {HTMLElement} heading  The section heading.
   * @param {HTMLElement} content  The section content.
   * @protected
   */
  _onEnd(heading, content) {
    content.style.height = "";
    this.#ongoing.delete(heading);
  }

  /* -------------------------------------------- */

  /**
   * Cancel an ongoing effect.
   * @param {HTMLElement} heading  The section heading.
   */
  #cancelOngoing(heading) {
    const ongoing = this.#ongoing.get(heading);
    const content = this.#sections.get(heading);
    if ( ongoing && content ) content.removeEventListener("transitionend", ongoing);
  }

  /* -------------------------------------------- */

  /**
   * Save the accordion state.
   * @protected
   */
  _saveCollapsedState() {
    this.#collapsed = [];
    for ( const heading of this.#sections.keys() ) {
      this.#collapsed.push(heading.parentElement.classList.contains("collapsed"));
    }
  }

  /* -------------------------------------------- */

  /**
   * Restore the accordion state.
   * @protected
   */
  _restoreCollapsedState() {
    const entries = Array.from(this.#sections.entries());
    for ( let i = 0; i < entries.length; i++ ) {
      const collapsed = this.#collapsed[i];
      const [heading, content] = entries[i];
      if ( collapsed ) this._onCollapseSection(heading, content, { animate: false });
    }
  }
}

/**
 * Configuration application for Starting Equipment.
 */
class StartingEquipmentConfig extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["starting-equipment"],
    form: {
      submitOnChange: true
    },
    position: {
      width: 480
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    config: {
      template: "systems/dnd5e/templates/apps/starting-equipment-config.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get title() {
    return game.i18n.localize("DND5E.StartingEquipment.Action.Configure");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);

    const processEntry = async (entry, depth=1) => {
      const data = {
        id: entry._id, entry, depth,
        groupType: entry.type in EquipmentEntryData.GROUPING_TYPES,
        validTypes: depth < 3 ? EquipmentEntryData.TYPES : EquipmentEntryData.OPTION_TYPES
      };
      if ( entry.type in EquipmentEntryData.GROUPING_TYPES ) {
        data.children = await Promise.all(entry.children.map(c => processEntry(c, depth + 1)));
      } else if ( entry.type === "linked" ) {
        data.linked = fromUuidSync(entry.key);
        data.showRequireProficiency = ["equipment", "tool", "weapon"].includes(data.linked?.type);
      }
      return data;
    };
    context.entries = await Promise.all(
      this.document.system.startingEquipment
        .filter(e => !e.group)
        .sort((lhs, rhs) => lhs.sort - rhs.sort)
        .map(e => processEntry(e))
    );

    return context;
  }

  /* -------------------------------------------- */
  /*  Life-Cycle Handlers                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop({
      dragSelector: ".drag-bar",
      dropSelector: null,
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */
  /*  Event Listeners                             */
  /* -------------------------------------------- */

  /**
   * Handle an action.
   * @param {HTMLElement} element       The element on which the action is being performed.
   * @param {object} [options={}]
   * @param {string} [options.action]   The specific action to perform.
   * @param {number} [options.depth]    Depth of the element being acted upon.
   * @param {string} [options.entryId]  ID of the entry to act upon.
   */
  _onAction(element, { action, depth, entryId }={}) {
    this.submit({ updateData: {
      action: action ?? element.closest("[data-action]")?.dataset.action,
      depth: depth ?? (Number(event.target.closest("[data-depth]")?.dataset.depth ?? 0) + 1),
      entryId: entryId ?? element.closest("[data-entry-id]")?.dataset.entryId
    } });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickAction(event, target) {
    this._onAction(target);
  }

  /* -------------------------------------------- */
  /*  Form Handling                               */
  /* -------------------------------------------- */

  /** @override */
  _prepareSubmitData(event, form, formData) {
    const submitData = this._processFormData(event, form, formData);
    // Skip the validation step here because it causes a bunch of problems with providing array
    // updates when using the `submit` method
    return submitData;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _processSubmitData(event, form, submitData) {
    let { action, depth, entryId, linkedUuid, startingEquipment } = submitData;
    startingEquipment = Object.values(startingEquipment ?? {});

    const highestSort = startingEquipment.reduce((sort, i) => i.sort > sort ? i.sort : sort, 0);
    switch ( action ) {
      case "add-entry":
        startingEquipment.push({
          _id: foundry.utils.randomID(),
          group: entryId,
          sort: highestSort + CONST.SORT_INTEGER_DENSITY,
          type: (depth < 3) && !linkedUuid ? "OR" : "linked",
          key: linkedUuid
        });
        break;
      case "delete-entry":
        const deleteIds = new Set();
        const getDeleteIds = entry => {
          deleteIds.add(entry._id);
          entry.children?.forEach(c => getDeleteIds(c));
        };
        getDeleteIds(this.document.system.startingEquipment.find(i => i._id === entryId));
        startingEquipment = startingEquipment.filter(e => !deleteIds.has(e._id));
        break;
    }

    await super._processSubmitData(event, form, { "system.startingEquipment": startingEquipment });
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    const entry = event.target.closest("[data-entry-id]");
    if ( !entry ) return;
    event.dataTransfer.setData("text/plain", JSON.stringify({
      type: "equipment-entry", uuid: this.document.uuid, entryId: entry.dataset.entryId
    }));
    const box = entry.getBoundingClientRect();
    event.dataTransfer.setDragImage(entry, box.width - 6, box.height / 2);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    // Try to extract the data
    const data = TextEditor.getDragEventData(event);

    // Handle re-ordering of list
    if ( data?.entryId && (data.uuid === this.document.uuid) ) return this._onSortEntry(event, data);

    // Handle dropping linked items
    if ( data?.type !== "Item" ) return;
    const item = await Item.implementation.fromDropData(data);

    // Validate that this is a physical item
    if ( !item.system.constructor._schemaTemplates?.includes(PhysicalItemTemplate) ) {
      ui.notifications.error(game.i18n.format("DND5E.StartingEquipment.Warning.ItemTypeInvalid", {
        type: game.i18n.localize(CONFIG.Item.typeLabels[item.type])
      }));
      return null;
    }

    // Determine where this was dropped
    const closestDrop = event.target.closest(
      '[data-entry-type="AND"], [data-entry-type="OR"], [data-entry-type="linked"]'
    );
    const { entryId, entryType } = closestDrop?.dataset ?? {};

    // If no closest entry, create at top level, or if closest is a group, create inside that group
    if ( !entryId || (entryType in EquipmentEntryData.GROUPING_TYPES) ) this.submit({ updateData: {
      action: "add-entry",
      entryId,
      linkedUuid: item.uuid
    } });

    // If closest entry is linked, set its key to be this uuid
    else if ( entryType === "linked" ) this.submit({ updateData: {
      [`startingEquipment.${entryId}.key`]: item.uuid
    } });
  }

  /* -------------------------------------------- */

  /**
   * Sort an entry on drop.
   * @param {DragEvent} event  Triggering drop event.
   * @param {object} data      Drag event data.
   */
  _onSortEntry(event, data) {
    const dropArea = event.target.closest("[data-entry-id]");
    const dragEntry = this.document.system.startingEquipment.find(e => e._id === data?.entryId);
    const dropEntry = this.document.system.startingEquipment.find(e => e._id === dropArea?.dataset.entryId);

    // If drag entry & drop entry are the same, or drop entry is drag entry's group, do nothing
    if ( (dropEntry?._id === dragEntry._id) || (dropEntry?._id === dragEntry.group) ) return;

    let updateData;
    let sortBefore;
    let target;

    // If drop entry is a group, move drag entry into it
    if ( dropEntry?.type in EquipmentEntryData.GROUPING_TYPES ) {
      let depth = Number(dropArea.dataset.depth) + 1;
      if ( dragEntry.children?.length ) {
        depth += 1;
        if ( dragEntry.children.some(c => c.type in EquipmentEntryData.GROUPING_TYPES) ) depth += 1;
      }
      if ( depth > 3 ) {
        ui.notifications.warn("DND5E.StartingEquipment.Warning.Depth", { localize: true });
        return;
      }
      updateData = { [`startingEquipment.${dragEntry._id}.group`]: dropEntry._id };
      target = dropEntry.children.pop();
    }

    // If drag entry and drop entry are in the same group, perform relative sort
    else if ( dropEntry && (dropEntry.group === dragEntry.group) ) {
      target = dropEntry;
    }

    // If dropped outside any entry, move to top level and sort to top or bottom of list
    else if ( !dropEntry ) {
      updateData = { [`startingEquipment.${dragEntry._id}.group`]: null };
      const box = this.element.getBoundingClientRect();
      sortBefore = (event.clientY - box.y) < (box.height * .75);
      const sortedEntries = this.document.system.startingEquipment.filter(e => !e.group)
        .sort((lhs, rhs) => lhs.sort - rhs.sort);
      target = sortBefore ? sortedEntries.shift() : sortedEntries.pop();
    }

    // If they are in different groups, move entry to new group and then sort
    else if ( dropEntry.group !== dragEntry.group ) {
      updateData = { [`startingEquipment.${dragEntry._id}.group`]: dropEntry.group };
      target = dropEntry;
    }

    if ( target && (target !== dragEntry) ) {
      updateData ??= {};
      const siblings = this.document.system.startingEquipment.filter(s => s._id !== dragEntry._id);
      const sortUpdates = SortingHelpers.performIntegerSort(dragEntry, { target, siblings, sortBefore });
      for ( const update of sortUpdates ) {
        updateData[`startingEquipment.${update.target._id}.sort`] = update.update.sort;
      }
    }

    if ( updateData ) this.submit({ updateData });
  }
}

/**
 * Override and extend the core ItemSheet implementation to handle specific item types.
 */
class ItemSheet5e extends ItemSheet {
  constructor(...args) {
    super(...args);

    this._accordions = this._createAccordions();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      width: 560,
      classes: ["dnd5e", "sheet", "item"],
      resizable: true,
      scrollY: [
        ".tab[data-tab=details]",
        ".tab[data-tab=effects] .items-list",
        ".tab[data-tab=description] .editor-content",
        ".tab[data-tab=advancement] .items-list"
      ],
      tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "description"}],
      dragDrop: [
        {dragSelector: "[data-effect-id]", dropSelector: "form"},
        {dragSelector: ".advancement-item", dropSelector: ".advancement"}
      ],
      accordions: [{
        headingSelector: ".description-header", contentSelector: ".editor"
      }],
      elements: {
        effects: "dnd5e-effects"
      },
      legacyDisplay: true,
      contextMenu: ContextMenu
    });
  }

  /* -------------------------------------------- */

  /**
   * Whether advancements on embedded items should be configurable.
   * @type {boolean}
   */
  advancementConfigurationMode = false;

  /* -------------------------------------------- */

  /**
   * The description currently being edited.
   * @type {string}
   */
  editingDescriptionTarget;

  /* -------------------------------------------- */

  /** @inheritDoc */
  get template() {
    return `systems/dnd5e/templates/items/${this.item.type}.hbs`;
  }

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _render(force, options) {
    if ( !this.editingDescriptionTarget ) this._accordions.forEach(accordion => accordion._saveCollapsedState());
    return super._render(force, options);
  }

  /* -------------------------------------------- */

  /** @override */
  async getData(options) {
    const context = await super.getData(options);
    const item = context.item;
    const source = item.toObject();

    // Game system configuration
    context.config = CONFIG.DND5E;

    // Item rendering data
    foundry.utils.mergeObject(context, {
      source: source.system,
      system: item.system,
      labels: item.labels,
      isEmbedded: item.isEmbedded,
      advancementEditable: (this.advancementConfigurationMode || !item.isEmbedded) && context.editable,
      rollData: this.item.getRollData(),
      user: game.user,

      // Item Type, Status, and Details
      itemType: game.i18n.localize(CONFIG.Item.typeLabels[this.item.type]),
      itemStatus: this._getItemStatus(),
      itemProperties: this._getItemProperties(),
      baseItems: await this._getItemBaseTypes(),
      isPhysical: item.system.hasOwnProperty("quantity"),

      // Action Details
      isHealing: item.system.actionType === "heal",
      isFlatDC: item.system.save?.scaling === "flat",
      isLine: ["line", "wall"].includes(item.system.target?.type),
      isFormulaRecharge: !!CONFIG.DND5E.limitedUsePeriods[item.system.uses?.per]?.formula,
      isCostlessAction: item.system.activation?.type in CONFIG.DND5E.staticAbilityActivationTypes,

      // Identified state
      isIdentifiable: "identified" in item.system,
      isIdentified: item.system.identified !== false,

      // Vehicles
      isCrewed: item.system.activation?.type === "crew",

      // Armor Class
      hasDexModifier: item.isArmor && (item.system.type.value !== "shield"),

      // Advancement
      advancement: this._getItemAdvancement(item),

      // Enchantment
      appliedEnchantments: item.system.appliedEnchantments?.map(enchantment => ({
        enchantment,
        name: enchantment.parent._source.name,
        actor: enchantment.parent.actor,
        item: enchantment.parent
      })),

      // Prepare Active Effects
      effects: EffectsElement.prepareCategories(item.effects, { parent: this.item }),
      elements: this.options.elements,

      concealDetails: !game.user.isGM && (this.document.system.identified === false)
    });
    if ( !item.isEmbedded && foundry.utils.isEmpty(context.abilityConsumptionTargets) ) {
      context.abilityConsumptionHint = (this.item.system.consume?.type === "attribute")
        ? "DND5E.ConsumeHint.Attribute" : "DND5E.ConsumeHint.Item";
    }

    if ( ("properties" in item.system) && (item.type in CONFIG.DND5E.validProperties) ) {
      context.properties = item.system.validProperties.reduce((obj, k) => {
        const v = CONFIG.DND5E.itemProperties[k];
        obj[k] = {
          label: v.label,
          selected: item.system.properties.has(k)
        };
        return obj;
      }, {});
      if ( item.type !== "spell" ) context.properties = sortObjectEntries(context.properties, "label");
    }

    // Handle item subtypes.
    if ( ["feat", "loot", "consumable"].includes(item.type) ) {
      const name = item.type === "feat" ? "feature" : item.type;
      const itemTypes = CONFIG.DND5E[`${name}Types`][item.system.type.value];
      if ( itemTypes ) {
        context.itemType = itemTypes.label;
        context.itemSubtypes = itemTypes.subtypes;
      }
    }

    // Enrich HTML description
    const enrichmentOptions = {
      secrets: item.isOwner, relativeTo: this.item, rollData: context.rollData
    };
    context.enriched = {
      description: await TextEditor.enrichHTML(item.system.description.value, enrichmentOptions),
      unidentified: await TextEditor.enrichHTML(item.system.unidentified?.description, enrichmentOptions),
      chat: await TextEditor.enrichHTML(item.system.description.chat, enrichmentOptions)
    };
    if ( this.editingDescriptionTarget ) {
      context.editingDescriptionTarget = this.editingDescriptionTarget;
      context.enriched.editing = await TextEditor.enrichHTML(
        foundry.utils.getProperty(context, this.editingDescriptionTarget), enrichmentOptions
      );
    }
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Get the display object used to show the advancement tab.
   * @param {Item5e} item  The item for which the advancement is being prepared.
   * @returns {object}     Object with advancement data grouped by levels.
   */
  _getItemAdvancement(item) {
    if ( !item.system.advancement ) return {};
    const advancement = {};
    const configMode = !item.parent || this.advancementConfigurationMode;
    const legacyDisplay = this.options.legacyDisplay;
    const maxLevel = !configMode
      ? (item.system.levels ?? item.class?.system.levels ?? item.parent.system.details?.level ?? -1) : -1;

    // Improperly configured advancements
    if ( item.advancement.needingConfiguration.length ) {
      advancement.unconfigured = {
        items: item.advancement.needingConfiguration.map(a => ({
          id: a.id,
          order: a.constructor.order,
          title: a.title,
          icon: a.icon,
          classRestriction: a.classRestriction,
          configured: false,
          tags: this._getItemAdvancementTags(a),
          classes: [a.icon?.endsWith(".svg") ? "svg" : ""].filterJoin(" ")
        })),
        configured: "partial"
      };
    }

    // All other advancements by level
    for ( let [level, advancements] of Object.entries(item.advancement.byLevel) ) {
      if ( !configMode ) advancements = advancements.filter(a => a.appliesToClass);
      const items = advancements.map(advancement => ({
        id: advancement.id,
        order: advancement.sortingValueForLevel(level),
        title: advancement.titleForLevel(level, { configMode, legacyDisplay }),
        icon: advancement.icon,
        classRestriction: advancement.classRestriction,
        summary: advancement.summaryForLevel(level, { configMode, legacyDisplay }),
        configured: advancement.configuredForLevel(level),
        tags: this._getItemAdvancementTags(advancement),
        value: advancement.valueForLevel?.(level),
        classes: [advancement.icon?.endsWith(".svg") ? "svg" : ""].filterJoin(" ")
      }));
      if ( !items.length ) continue;
      advancement[level] = {
        items: items.sort((a, b) => a.order.localeCompare(b.order, game.i18n.lang)),
        configured: (level > maxLevel) ? false : items.some(a => !a.configured) ? "partial" : "full"
      };
    }
    return advancement;
  }

  /* -------------------------------------------- */

  /**
   * Prepare tags for an Advancement.
   * @param {Advancement} advancement  The Advancement.
   * @returns {{label: string, icon: string}[]}
   * @protected
   */
  _getItemAdvancementTags(advancement) {
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Get the base weapons and tools based on the selected type.
   * @param {object} [context]        Sheet preparation context.
   * @returns {Promise<object|null>}  Object with base items for this type formatted for selectOptions.
   * @protected
   */
  async _getItemBaseTypes(context) {
    const baseIds = this.item.type === "equipment" ? {
      ...CONFIG.DND5E.armorIds,
      ...CONFIG.DND5E.shieldIds
    } : CONFIG.DND5E[`${this.item.type}Ids`];
    if ( baseIds === undefined ) return null;

    const baseType = context?.source.type.value ?? this.item.system.type.value;

    const items = {};
    for ( const [name, id] of Object.entries(baseIds) ) {
      const baseItem = await getBaseItem(id);
      if ( baseType !== baseItem?.system?.type?.value ) continue;
      items[name] = baseItem.name;
    }
    if ( foundry.utils.isEmpty(items) ) return null;
    return Object.fromEntries(Object.entries(items).sort((lhs, rhs) => lhs[1].localeCompare(rhs[1], game.i18n.lang)));
  }

  /* -------------------------------------------- */

  /**
   * Get the text item status which is shown beneath the Item type in the top-right corner of the sheet.
   * @returns {string|null}  Item status string if applicable to item's type.
   * @protected
   */
  _getItemStatus() {
    switch ( this.item.type ) {
      case "class":
        return game.i18n.format("DND5E.LevelCount", {ordinal: this.item.system.levels.ordinalString()});
      case "equipment":
      case "weapon":
        return game.i18n.localize(this.item.system.equipped ? "DND5E.Equipped" : "DND5E.Unequipped");
      case "feat":
      case "consumable":
        return this.item.system.type.label;
      case "spell":
        return CONFIG.DND5E.spellPreparationModes[this.item.system.preparation.mode]?.label;
      case "tool":
        return CONFIG.DND5E.proficiencyLevels[this.item.system.prof?.multiplier || 0];
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Retrieve the list of fields that are currently modified by Active Effects on the Item.
   * @returns {string[]}
   * @protected
   */
  _getItemOverrides() {
    const overrides = Object.keys(foundry.utils.flattenObject(this.item.overrides ?? {}));
    this.item.system.getItemOverrides?.(overrides);
    if ( "properties" in this.item.system ) {
      ActiveEffect5e.addOverriddenChoices(this.item, "system.properties", "system.properties", overrides);
    }
    if ( ("damage" in this.item.system) && foundry.utils.getProperty(this.item.overrides, "system.damage.parts") ) {
      overrides.push("damage-control");
      Array.fromRange(this.item.system.damage.parts.length).forEach(index => overrides.push(
        `system.damage.parts.${index}.0`, `system.damage.parts.${index}.1`
      ));
    }
    return overrides;
  }

  /* -------------------------------------------- */

  /**
   * Get the Array of item properties which are used in the small sidebar of the description tab.
   * @returns {string[]}   List of property labels to be shown.
   * @private
   */
  _getItemProperties() {
    const props = [];
    const labels = this.item.labels;
    switch ( this.item.type ) {
      case "consumable":
      case "weapon":
        if ( this.item.isMountable ) props.push(labels.armor);
        const ip = CONFIG.DND5E.itemProperties;
        const vp = CONFIG.DND5E.validProperties[this.item.type];
        this.item.system.properties.forEach(k => {
          if ( vp.has(k) ) props.push(ip[k].label);
        });
        break;
      case "equipment":
        props.push(CONFIG.DND5E.equipmentTypes[this.item.system.type.value]);
        if ( this.item.isArmor || this.item.isMountable ) props.push(labels.armor);
        break;
      case "feat":
        props.push(labels.featType);
        break;
      case "spell":
        props.push(labels.components.vsm, labels.materials, ...labels.components.tags);
        break;
    }

    // Action type
    if ( this.item.system.actionType ) {
      props.push(CONFIG.DND5E.itemActionTypes[this.item.system.actionType]);
    }

    // Action usage
    if ( (this.item.type !== "weapon") && !foundry.utils.isEmpty(this.item.system.activation) ) {
      props.push(labels.activation, labels.range, labels.target, labels.duration);
    }
    return props.filter(p => !!p);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeTab(event, tabs, active) {
    this.setPosition({ height: "auto" });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async activateEditor(name, options={}, initialContent="") {
    options.relativeLinks = true;
    options.plugins = {
      menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
        compact: true,
        destroyOnSave: true,
        onSave: () => {
          this.saveEditor(name, {remove: true});
          this.editingDescriptionTarget = null;
        }
      })
    };
    this.editors[name].initial = initialContent = foundry.utils.getProperty(this.item._source, name);
    return super.activateEditor(name, options, initialContent);
  }

  /* -------------------------------------------- */
  /*  Form Submission                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _getSubmitData(updateData={}) {
    const formData = foundry.utils.expandObject(super._getSubmitData(updateData));

    // Handle Damage array
    const damage = formData.system?.damage;
    if ( damage && !foundry.utils.getProperty(this.item.overrides, "system.damage.parts") ) {
      damage.parts = Object.values(damage?.parts || {}).map(d => [d[0] || "", d[1] || ""]);
    }

    // Handle properties
    if ( foundry.utils.hasProperty(formData, "system.properties") ) {
      const keys = new Set(Object.keys(formData.system.properties));
      const preserve = new Set(this.item._source.system.properties ?? []).difference(keys);
      formData.system.properties = [...filteredKeys(formData.system.properties), ...preserve];
    }

    // Check max uses formula
    const uses = formData.system?.uses;
    if ( uses?.max ) {
      const maxRoll = new Roll(uses.max);
      if ( !maxRoll.isDeterministic ) {
        uses.max = this.item._source.system.uses.max;
        this.form.querySelector("input[name='system.uses.max']").value = uses.max;
        ui.notifications.error(game.i18n.format("DND5E.FormulaCannotContainDiceError", {
          name: game.i18n.localize("DND5E.LimitedUses")
        }));
        return {};
      }
    }

    // Check duration value formula
    const duration = formData.system?.duration;
    if ( duration?.value ) {
      const durationRoll = new Roll(duration.value);
      if ( !durationRoll.isDeterministic ) {
        duration.value = this.item._source.system.duration.value;
        this.form.querySelector("input[name='system.duration.value']").value = duration.value;
        ui.notifications.error(game.i18n.format("DND5E.FormulaCannotContainDiceError", {
          name: game.i18n.localize("DND5E.Duration")
        }));
        return {};
      }
    }

    // Check class identifier
    if ( formData.system?.identifier && !dnd5e.utils.validators.isValidIdentifier(formData.system.identifier) ) {
      formData.system.identifier = this.item._source.system.identifier;
      this.form.querySelector("input[name='system.identifier']").value = formData.system.identifier;
      ui.notifications.error("DND5E.IdentifierError", {localize: true});
      return {};
    }

    // Return the flattened submission data
    return foundry.utils.flattenObject(formData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    if ( !this.editingDescriptionTarget ) this._accordions.forEach(accordion => accordion.bind(html[0]));
    if ( this.isEditable ) {
      html.find(".config-button").click(this._onConfigMenu.bind(this));
      html.find(".damage-control").click(this._onDamageControl.bind(this));
      html.find(".enchantment-button").click(this._onEnchantmentAction.bind(this));
      html.find(".advancement .item-control").click(event => {
        const t = event.currentTarget;
        if ( t.dataset.action ) this._onAdvancementAction(t, t.dataset.action);
      });
      html.find(".description-edit").click(event => {
        if ( event.currentTarget.ariaDisabled ) return;
        event.stopPropagation();
        this.editingDescriptionTarget = event.currentTarget.dataset.target;
        this.render();
      });
      for ( const override of this._getItemOverrides() ) {
        for ( const element of html[0].querySelectorAll(`[name="${override}"]`) ) {
          element.disabled = true;
          element.dataset.tooltip = "DND5E.ENCHANTMENT.Warning.Override";
        }
        for ( const element of html[0].querySelectorAll(`[data-target="${override}"]`) ) {
          element.ariaDisabled = true;
          element.dataset.tooltip = "DND5E.ENCHANTMENT.Warning.Override";
        }
        if ( override === "damage-control" ) html[0].querySelectorAll(".damage-control").forEach(e => e.remove());
      }
    }
    html[0].querySelectorAll('[data-action="view"]').forEach(e => e.addEventListener("click", this._onView.bind(this)));
  }

  /* -------------------------------------------- */

  /**
   * Handle spawning the configuration applications.
   * @param {Event} event   The click event which originated the selection.
   * @protected
   */
  _onConfigMenu(event) {
    event.preventDefault();
    event.stopPropagation();
    const button = event.currentTarget;
    let app;
    switch ( button.dataset.action ) {
      case "movement":
      case "senses":
        app = new MovementSensesConfig({ document: this.item, type: button.dataset.action });
        break;
      case "source":
        app = new SourceConfig({ document: this.item, keyPath: "system.source" });
        break;
      case "starting-equipment":
        app = new StartingEquipmentConfig({ document: this.item });
        break;
      case "type":
        app = new CreatureTypeConfig({ document: this.item, keyPath: "type" });
        break;
    }
    app?.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Add or remove a damage part from the damage formula.
   * @param {Event} event             The original click event.
   * @returns {Promise<Item5e>|null}  Item with updates applied.
   * @private
   */
  async _onDamageControl(event) {
    event.preventDefault();
    const a = event.currentTarget;

    // Add new damage component
    if ( a.classList.contains("add-damage") ) {
      await this._onSubmit(event);  // Submit any unsaved changes
      const damage = this.item.system.damage;
      return this.item.update({"system.damage.parts": damage.parts.concat([["", ""]])});
    }

    // Remove a damage component
    if ( a.classList.contains("delete-damage") ) {
      await this._onSubmit(event);  // Submit any unsaved changes
      const li = a.closest(".damage-part");
      const damage = foundry.utils.deepClone(this.item.system.damage);
      damage.parts.splice(Number(li.dataset.damagePart), 1);
      return this.item.update({"system.damage.parts": damage.parts});
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle actions on entries in the enchanted items list.
   * @param {PointerEvent} event  Triggering click event.
   * @private
   */
  async _onEnchantmentAction(event) {
    event.preventDefault();
    const enchantment = fromUuidSync(event.currentTarget.closest("[data-enchantment-uuid]")?.dataset.enchantmentUuid);
    if ( !enchantment ) return;
    switch ( event.currentTarget.dataset.action ) {
      case "removeEnchantment":
        await enchantment.delete();
        this.render();
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle actions on a view sheet button.
   * @param {PointerEvent} event  Triggering click event.
   * @private
   */
  async _onView(event) {
    event.preventDefault();
    const doc = await fromUuid(event.currentTarget.dataset.uuid);
    doc?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragStart(selector) {
    if ( [".advancement-item", "[data-effect-id]", ".activity[data-activity-id]"].includes(selector) ) return true;
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragDrop(selector) {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    const li = event.currentTarget;
    if ( event.target.classList.contains("content-link") ) return;

    // Create drag data
    let dragData;

    // Active Effect
    if ( li.dataset.effectId ) {
      const effect = this.item.effects.get(li.dataset.effectId);
      dragData = effect.toDragData();
    } else if ( li.classList.contains("advancement-item") ) {
      dragData = this.item.advancement.byId[li.dataset.id]?.toDragData();
    }

    if ( !dragData ) return;

    // Set data transfer
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    const item = this.item;

    /**
     * A hook event that fires when some useful data is dropped onto an ItemSheet5e.
     * @function dnd5e.dropItemSheetData
     * @memberof hookEvents
     * @param {Item5e} item                  The Item5e
     * @param {ItemSheet5e} sheet            The ItemSheet5e application
     * @param {object} data                  The data that has been dropped onto the sheet
     * @returns {boolean}                    Explicitly return `false` to prevent normal drop handling.
     */
    const allowed = Hooks.call("dnd5e.dropItemSheetData", item, this, data);
    if ( allowed === false ) return;

    switch ( data.type ) {
      case "ActiveEffect":
        return this._onDropActiveEffect(event, data);
      case "Activity":
        return this._onDropActivity(event, data);
      case "Item":
        return this._onDropItem(event, data);
      case "Advancement":
        return this._onDropAdvancement(event, data);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of ActiveEffect data onto an Item Sheet
   * @param {DragEvent} event                  The concluding DragEvent which contains drop data
   * @param {object} data                      The data transfer extracted from the event
   * @returns {Promise<ActiveEffect|boolean>}  The created ActiveEffect object or false if it couldn't be created.
   * @protected
   */
  async _onDropActiveEffect(event, data) {
    const effect = await ActiveEffect.implementation.fromDropData(data);
    if ( !this.item.isOwner || !effect
      || (this.item.uuid === effect.parent?.uuid)
      || (this.item.uuid === effect.origin) ) return false;
    const effectData = effect.toObject();
    const options = { parent: this.item, keepOrigin: false };

    if ( effect.type === "enchantment" ) {
      effectData.origin ??= effect.parent.uuid;
      options.keepOrigin = true;
      options.dnd5e = {
        enchantmentProfile: effect.id,
        activityId: data.activityId ?? effect.parent?.system.activities?.getByType("enchant").find(a =>
          a.effects.some(e => e._id === effect.id)
        )?.id
      };
    }

    return ActiveEffect.create(effectData, options);
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of an advancement or item with advancements onto the advancements tab.
   * @param {DragEvent} event                  The concluding DragEvent which contains drop data.
   * @param {object} data                      The data transfer extracted from the event.
   * @returns {Promise}
   */
  async _onDropAdvancement(event, data) {
    if ( !this.item.system.advancement ) return;

    let advancements;
    let showDialog = false;
    if ( data.type === "Advancement" ) {
      advancements = [await fromUuid(data.uuid)];
    } else if ( data.type === "Item" ) {
      const item = await Item.implementation.fromDropData(data);
      if ( !item?.system.advancement ) return false;
      advancements = Object.values(item.advancement.byId);
      showDialog = true;
    } else {
      return false;
    }
    advancements = advancements.filter(a => {
      const validItemTypes = CONFIG.DND5E.advancementTypes[a.constructor.typeName]?.validItemTypes
        ?? a.metadata.validItemTypes;
      return !this.item.advancement.byId[a.id]
        && validItemTypes.has(this.item.type)
        && a.constructor.availableForItem(this.item);
    });

    // Display dialog prompting for which advancements to add
    if ( showDialog ) {
      try {
        advancements = await AdvancementMigrationDialog.createDialog(this.item, advancements);
      } catch(err) {
        return false;
      }
    }

    if ( !advancements.length ) return false;
    if ( this.item.actor?.system.metadata?.supportsAdvancement && !game.settings.get("dnd5e", "disableAdvancements") ) {
      const manager = AdvancementManager.forNewAdvancement(this.item.actor, this.item.id, advancements);
      if ( manager.steps.length ) return manager.render(true);
    }

    // If no advancements need to be applied, just add them to the item
    const advancementArray = this.item.system.toObject().advancement;
    advancementArray.push(...advancements.map(a => a.toObject()));
    this.item.update({"system.advancement": advancementArray});
  }

  /* -------------------------------------------- */

  /**
   * Handle one of the advancement actions from the buttons or context menu.
   * @param {Element} target  Button or context menu entry that triggered this action.
   * @param {string} action   Action being triggered.
   * @returns {Promise|void}
   */
  _onAdvancementAction(target, action) {
    const id = target.closest(".advancement-item")?.dataset.id;
    const advancement = this.item.advancement.byId[id];
    let manager;
    if ( ["edit", "delete", "duplicate"].includes(action) && !advancement ) return;
    switch (action) {
      case "add": return game.dnd5e.applications.advancement.AdvancementSelection.createDialog(this.item);
      case "edit": return new advancement.constructor.metadata.apps.config(advancement).render(true);
      case "delete": return advancement.deleteDialog();
      case "duplicate": return this.item.duplicateAdvancement(id);
      case "modify-choices":
        const level = target.closest("[data-level]")?.dataset.level;
        manager = AdvancementManager.forModifyChoices(this.item.actor, this.item.id, Number(level));
        if ( manager.steps.length ) manager.render(true);
        return;
      case "toggle-configuration":
        this.advancementConfigurationMode = !this.advancementConfigurationMode;
        return this.render();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onSubmit(...args) {
    if ( this._tabs[0].active === "details" ) this.position.height = "auto";
    await super._onSubmit(...args);
  }

  /* -------------------------------------------- */

  /**
   * Instantiate accordion widgets.
   * @returns {Accordion[]}
   * @protected
   */
  _createAccordions() {
    return this.options.accordions.map(config => new Accordion(config));
  }
}

/**
 * Adds common V2 Item sheet functionality.
 * @param {typeof ItemSheet5e} Base  The base class being mixed.
 * @returns {typeof ItemSheetV2}
 */
function ItemSheetV2Mixin(Base) {
  return class ItemSheetV2 extends DocumentSheetV2Mixin(Base) {
    /** @override */
    static TABS = [
      { tab: "contents", label: "DND5E.Contents", condition: this.itemHasContents.bind(this) },
      { tab: "description", label: "DND5E.Description" },
      { tab: "details", label: "DND5E.Details", condition: this.isItemIdentified.bind(this) },
      { tab: "activities", label: "DND5E.ACTIVITY.Title.other", condition: this.itemHasActivities.bind(this) },
      { tab: "effects", label: "DND5E.Effects", condition: this.itemHasEffects.bind(this) },
      { tab: "advancement", label: "DND5E.AdvancementTitle", condition: this.itemHasAdvancements.bind(this) }
    ];

    /**
     * Store the collapsed state of the description boxes.
     * @type {Record<string, boolean>}
     * @protected
     */
    _collapsed = {};

    /**
     * Track the set of filters which are applied.
     * @type {Record<string, FilterState5e>}
     * @protected
     */
    _filters = {
      effects: { name: "", properties: new Set() },
      inventory: { name: "", properties: new Set() }
    };

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /** @inheritDoc */
    async _renderOuter() {
      const html = await super._renderOuter();

      // Equipped & Identified
      if ( this.isEditable ) {
        const buttons = [];
        if ( "identified" in this.item.system ) buttons.push({
          property: "system.identified", icon: "fas fa-wand-sparkles", classes: "state-toggle toggle-identified"
        });
        if ( "equipped" in this.item.system ) buttons.push({
          property: "system.equipped", icon: "fas fa-shield-halved", classes: "state-toggle toggle-equipped"
        });

        const title = html[0].querySelector(".window-title");
        for ( const { property, icon, classes } of buttons ) {
          const anchor = document.createElement("a");
          anchor.className = `pseudo-header-button ${classes}`;
          Object.assign(anchor.dataset, { property, tooltipDirection: "DOWN" });
          anchor.innerHTML = `<i class="${icon}" inert></i>`;
          title.insertAdjacentElement("afterend", anchor);
          anchor.addEventListener("click", this._onToggleState.bind(this), { passive: true });
          anchor.addEventListener("dblclick", event => event.stopPropagation());
        }
      }

      this._renderSourceOuter(html);
      return html;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _render(force=false, options={}) {
      await super._render(force, options);

      const [identified] = this.element.find(".toggle-identified");
      if ( identified ) {
        const isIdentified = this.item.system.identified;
        const label = isIdentified ? "DND5E.Identified" : "DND5E.Unidentified.Title";
        identified.setAttribute("aria-label", game.i18n.localize(label));
        identified.dataset.tooltip = label;
        identified.classList.toggle("active", isIdentified);
      }

      const [equipped] = this.element.find(".toggle-equipped");
      if ( equipped ) {
        const isEquipped = this.item.system.equipped;
        const label = isEquipped ? "DND5E.Equipped" : "DND5E.Unequipped";
        equipped.setAttribute("aria-label", game.i18n.localize(label));
        equipped.dataset.tooltip = label;
        equipped.classList.toggle("active", isEquipped);
      }

      this._renderSource();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async getData(options) {
      const { system, labels, isEmbedded } = this.item;
      const context = {
        system, labels, isEmbedded,
        source: this.item.system.toObject(),
        item: this.item,
        owner: this.item.isOwner,
        config: CONFIG.DND5E,
        CONFIG: CONFIG.DND5E,
        user: game.user,

        // Physical items
        isPhysical: "quantity" in this.item.system,

        // Identified state
        isIdentifiable: "identified" in this.item.system,
        isIdentified: this.item.system.identified !== false,

        // Armor
        hasDexModifier: this.item.isArmor && (this.item.system.type.value !== "shield"),

        // Advancement
        advancement: this._getItemAdvancement(this.item),

        // Active Effects
        effects: EffectsElement.prepareCategories(this.item.effects, { parent: this.item }),
        elements: this.options.elements
      };

      context.editable = this.isEditable && (this._mode === this.constructor.MODES.EDIT);
      context.cssClass = context.editable ? "editable" : this.isEditable ? "interactable" : "locked";
      context.inputs = { ...foundry.applications.fields, ...dnd5e.applications.fields };
      const { description, identified, schema, unidentified, validProperties } = this.item.system;
      context.fields = schema.fields;
      if ( !context.editable ) context.source = context.system;

      // Physical items
      context.baseItems = await this._getItemBaseTypes(context);

      // Set some default collapsed states on first open.
      if ( foundry.utils.isEmpty(this._collapsed) ) Object.assign(this._collapsed, {
        "system.description.chat": true,
        "system.unidentified.description": game.user.isGM
      });
      context.collapsed = this._collapsed;

      // Tabs
      const activeTab = this._tabs?.[0]?.active ?? this.options.tabs[0].initial;
      context.cssClass += ` tab-${activeTab}`;
      context.tabs = this.constructor.TABS.reduce((tabs, { tab, label, condition }) => {
        if ( !condition || condition(this.item) ) tabs.push({
          tab, label,
          classes: ["item", "interface-only", activeTab === tab ? "active" : null].filterJoin(" ")
        });
        return tabs;
      }, []);

      // Name
      context.name = {
        value: this.item.name,
        editable: this.item._source.name,
        field: this.item.schema.getField("name")
      };

      if ( ("identified" in this.item.system) && (identified === false) ) {
        context.name = {
          value: unidentified.name,
          editable: this.item.system._source.unidentified.name,
          field: schema.getField("unidentified.name")
        };
      }

      // Properties
      context.properties = {
        active: [],
        object: Object.fromEntries((context.system.properties ?? []).map(p => [p, true])),
        options: (validProperties ?? []).reduce((arr, k) => {
          const { label } = CONFIG.DND5E.itemProperties[k];
          arr.push({
            label,
            value: k,
            selected: context.source.properties?.includes?.(k) ?? context.source.properties?.has?.(k)
          });
          return arr;
        }, [])
      };
      if ( this.item.type !== "spell" ) {
        context.properties.options.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
      }
      if ( game.user.isGM || (identified !== false) ) {
        context.properties.active.push(
          ...this.item.system.cardProperties ?? [],
          ...Object.values(this.item.labels.activations?.[0] ?? {}),
          ...this.item.system.equippableItemCardProperties ?? []
        );
      }

      // Item sub-types
      if ( ["feat", "loot", "consumable"].includes(this.item.type) ) {
        const name = this.item.type === "feat" ? "feature" : this.item.type;
        const itemTypes = CONFIG.DND5E[`${name}Types`][this.item.system.type.value];
        if ( itemTypes ) context.itemSubtypes = itemTypes.subtypes;
      }

      // Enrich HTML description
      const enrichmentOptions = {
        secrets: this.item.isOwner, relativeTo: this.item, rollData: this.item.getRollData()
      };
      context.enriched = {
        description: await TextEditor.enrichHTML(description.value, enrichmentOptions),
        unidentified: await TextEditor.enrichHTML(unidentified?.description, enrichmentOptions),
        chat: await TextEditor.enrichHTML(description.chat, enrichmentOptions)
      };
      if ( this.editingDescriptionTarget ) {
        context.editingDescriptionTarget = this.editingDescriptionTarget;
        context.enriched.editing = await TextEditor.enrichHTML(
          foundry.utils.getProperty(context, this.editingDescriptionTarget), enrichmentOptions
        );
      }

      // Sub-type context
      await this.item.system.getSheetData?.(context);

      context.properties.active = context.properties.active.filter(_ => _);

      return context;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    setPosition(position={}) {
      const newPosition = super.setPosition(position);
      // TODO: Unneeded in AppV2.
      this.element[0].style.height = "";
      return newPosition;
    }

    /* -------------------------------------------- */
    /*  Event Listeners & Handlers                  */
    /* -------------------------------------------- */

    /** @inheritDoc */
    activateListeners(html) {
      super.activateListeners(html);
      html.find(".description.collapsible > .header").on("click", this._onToggleOwnDescription.bind(this));

      // Play mode only.
      if ( this._mode === this.constructor.MODES.PLAY ) {
        html.find(".sheet-header .item-image").on("click", this._onShowIcon.bind(this));
        this._disableFields(this.form);
      }
    }

    /* -------------------------------------------- */

    /**
     * Disable form fields that aren't marked with the `interface-only` class.
     * @param {HTMLElement} form  The form element whose fields are being disabled.
     */
    _disableFields(form) {
      const selector = `:is(${[
        "INPUT", "SELECT", "TEXTAREA", "BUTTON", "DND5E-CHECKBOX", "COLOR-PICKER", "DOCUMENT-TAGS",
        "FILE-PICKER", "HUE-SLIDER", "MULTI-SELECT", "PROSE-MIRROR", "RANGE-PICKER", "STRING-TAGS"
      ].join(", ")}):not(.interface-only, .description-edit)`;
      for ( const element of form.querySelectorAll(selector) ) {
        if ( element.tagName === "TEXTAREA" ) element.readOnly = true;
        else element.disabled = true;
      }
    }

    /* -------------------------------------------- */

    /** @override */
    _disableOverriddenFields(html) {
      // When in edit mode, field values will be the base value, rather than the derived value, so it should not be
      // necessary to disable them anymore.
    }

    /* -------------------------------------------- */

    /**
     * Handle toggling one of the item's description categories.
     * @param {PointerEvent} event  The triggering event.
     * @protected
     */
    _onToggleOwnDescription(event) {
      const description = event.currentTarget.closest("[data-target]");
      if ( !description ) return;
      const { target } = description.dataset;
      description.classList.toggle("collapsed");
      this._collapsed[target] = description.classList.contains("collapsed");
    }

    /* -------------------------------------------- */

    /**
     * Handle toggling Item state.
     * @param {PointerEvent} event  The triggering event.
     * @protected
     */
    _onToggleState(event) {
      const { property } = event.currentTarget.dataset;
      const state = event.currentTarget.classList.contains("active");
      this.item.update({ [property]: !state });
    }

    /* -------------------------------------------- */

    /**
     * Handle showing the Item's art.
     * @protected
     */
    _onShowIcon() {
      const title = this.item.system.identified === false ? this.item.system.unidentified.name : this.item.name;
      new ImagePopout(this.item.img, { title, uuid: this.item.uuid }).render(true);
    }

    /* -------------------------------------------- */

    /** @override */
    _onCreateChild() {
      const activeTab = this._tabs?.[0]?.active ?? this.options.tabs[0].initial;

      if ( activeTab === "effects" ) {
        return ActiveEffect.implementation.create({
          name: this.document.name,
          img: this.document.img,
          origin: this.document.uuid
        }, { parent: this.item, renderSheet: true });
      }

      if ( activeTab === "advancement" ) {
        return game.dnd5e.applications.advancement.AdvancementSelection.createDialog(this.item);
      }

      if ( activeTab === "activities" ) {
        return dnd5e.documents.activity.UtilityActivity.createDialog({}, {
          parent: this.item,
          types: Object.entries(CONFIG.DND5E.activityTypes).filter(([, { configurable }]) => {
            return configurable !== false;
          }).map(([k]) => k)
        });
      }
    }

    /* -------------------------------------------- */
    /*  Helpers                                     */
    /* -------------------------------------------- */

    /**
     * Determine if an Item support Activities.
     * @param {Item5e} item  The Item.
     * @returns {boolean}
     */
    static itemHasActivities(item) {
      return this.isItemIdentified(item) && ("activities" in item.system);
    }

    /* -------------------------------------------- */

    /**
     * Determine if an Item support Advancements.
     * @param {Item5e} item  The Item.
     * @returns {boolean}
     */
    static itemHasAdvancements(item) {
      return "advancement" in item.system;
    }

    /* -------------------------------------------- */

    /**
     * Determine if an Item has contents.
     * @param {Item5e} item  The Item.
     * @returns {boolean}
     */
    static itemHasContents(item) {
      return item.system instanceof dnd5e.dataModels.item.ContainerData;
    }

    /* -------------------------------------------- */

    /**
     * Determine if an Item should show an effects tab.
     * @param {Item5e} item  The Item.
     * @returns {boolean}
     */
    static itemHasEffects(item) {
      return this.isItemIdentified(item) && ("activation" in item.system);
    }

    /* -------------------------------------------- */

    /**
     * Determine whether an Item is considered identified.
     * @param {Item5e} item  The Item.
     * @returns {boolean}
     */
    static isItemIdentified(item) {
      return game.user.isGM || (item.system.identified !== false);
    }
  };
}

/**
 * V2 Item sheet implementation.
 */
class ItemSheet5e2 extends ItemSheetV2Mixin(ItemSheet5e) {
  static get defaultOptions() {
    const options = foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e2", "sheet", "item"],
      width: 500,
      height: "auto",
      resizable: false,
      scrollY: [".tab.active"],
      elements: { effects: "dnd5e-effects" },
      legacyDisplay: false,
      contextMenu: ContextMenu5e
    });
    options.dragDrop.push({ dragSelector: ".activity[data-activity-id]", dropSelector: "form" });
    return options;
  }

  /* -------------------------------------------- */

  /** @override */
  get template() {
    return "systems/dnd5e/templates/items/item-sheet-2.hbs";
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = await super.getData(options);
    const { activities, building, craft, order, spellcasting, type } = this.item.system;
    const target = this.item.type === "spell" ? this.item.system.target : null;

    // Effects
    for ( const category of Object.values(context.effects) ) {
      category.effects = await category.effects.reduce(async (arr, effect) => {
        effect.updateDuration();
        const { id, name, img, disabled, duration } = effect;
        const source = await effect.getSource();
        arr = await arr;
        arr.push({
          id, name, img, disabled, duration, source,
          parent,
          durationParts: duration.remaining ? duration.label.split(", ") : [],
          hasTooltip: true
        });
        return arr;
      }, []);
    }

    // Hit Dice
    context.hitDieTypes = CONFIG.DND5E.hitDieTypes.map(d => ({ label: d, value: d }));

    // If using modern rules, do not show redundant artificer progression unless it is already selected.
    context.spellProgression = { ...CONFIG.DND5E.spellProgression };
    if ( (game.settings.get("dnd5e", "rulesVersion") === "modern") && (spellcasting?.progression !== "artificer") ) {
      delete context.spellProgression.artificer;
    }
    context.spellProgression = Object.entries(context.spellProgression).map(([value, label]) => ({ value, label }));

    // Activation
    context.activationTypes = [
      ...Object.entries(CONFIG.DND5E.activityActivationTypes).map(([value, { label, group }]) => {
        return { value, label, group };
      }),
      { value: "", label: "DND5E.NoneActionLabel" }
    ];

    // Targets
    context.targetTypes = [
      ...Object.entries(CONFIG.DND5E.individualTargetTypes).map(([value, { label }]) => {
        return { value, label, group: "DND5E.TargetTypeIndividual" };
      }),
      ...Object.entries(CONFIG.DND5E.areaTargetTypes).map(([value, { label }]) => {
        return { value, label, group: "DND5E.TargetTypeArea" };
      })
    ];
    context.scalarTarget = target?.affects?.type
      && (CONFIG.DND5E.individualTargetTypes[target.affects.type]?.scalar !== false);
    context.affectsPlaceholder = game.i18n.localize(`DND5E.TARGET.Count.${target?.template?.type ? "Every" : "Any"}`);

    // Range
    context.rangeTypes = [
      ...Object.entries(CONFIG.DND5E.rangeTypes).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.movementUnits).map(([value, { label }]) => {
        return { value, label, group: "DND5E.RangeDistance" };
      })
    ];

    // Duration
    context.durationUnits = [
      ...Object.entries(CONFIG.DND5E.specialTimePeriods).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.scalarTimePeriods).map(([value, label]) => {
        return { value, label, group: "DND5E.DurationTime" };
      }),
      ...Object.entries(CONFIG.DND5E.permanentTimePeriods).map(([value, label]) => {
        return { value, label, group: "DND5E.DurationPermanent" };
      })
    ];

    // Templates
    context.dimensions = target?.template?.dimensions;

    // Equipment
    context.equipmentTypes = [
      ...Object.entries(CONFIG.DND5E.miscEquipmentTypes).map(([value, label]) => ({ value, label })),
      ...Object.entries(CONFIG.DND5E.armorTypes).map(([value, label]) => ({ value, label, group: "DND5E.Armor" }))
    ];

    // Limited Uses
    context.data = { uses: context.source.uses };
    context.hasLimitedUses = this.item.system.hasLimitedUses;
    context.recoveryPeriods = CONFIG.DND5E.limitedUsePeriods.recoveryOptions;
    context.recoveryTypes = [
      { value: "recoverAll", label: "DND5E.USES.Recovery.Type.RecoverAll" },
      { value: "loseAll", label: "DND5E.USES.Recovery.Type.LoseAll" },
      { value: "formula", label: "DND5E.USES.Recovery.Type.Formula" }
    ];
    context.usesRecovery = (context.system.uses?.recovery ?? []).map((data, index) => ({
      data,
      fields: context.fields.uses.fields.recovery.element.fields,
      prefix: `system.uses.recovery.${index}.`,
      source: context.source.uses.recovery[index] ?? data,
      formulaOptions: data.period === "recharge" ? data.recharge?.options : null
    }));

    // Activities
    context.activities = (activities ?? []).filter(a => {
      return CONFIG.DND5E.activityTypes[a.type]?.configurable !== false;
    }).map(({ _id: id, name, img, sort }) => ({
      id, name, sort,
      img: { src: img, svg: img?.endsWith(".svg") }
    }));

    // Facilities
    if ( this.item.type === "facility" ) {
      context.orders = Object.entries(CONFIG.DND5E.facilities.orders).reduce((obj, [value, config]) => {
        const { label, basic, hidden } = config;
        if ( hidden ) return obj;
        // TODO: More hard-coding that we can potentially avoid.
        if ( value === "build" ) {
          if ( !building.built ) obj.executable.push({ value, label });
          return obj;
        }
        if ( value === "change" ) {
          if ( type.subtype === "garden" ) obj.executable.push({ value, label });
          return obj;
        }
        if ( type.value === "basic" ) {
          if ( !building.built ) return obj;
          if ( basic ) obj.executable.push({ value, label });
        } else if ( (type.value === "special") && !basic ) {
          obj.available.push({ value, label });
          if ( (value === order) || (value === "maintain") ) obj.executable.push({ value, label });
        }
        return obj;
      }, { available: [], executable: [] });
    }

    if ( (type?.value === "special") && ((order === "craft") || (order === "harvest")) ) {
      context.canCraft = true;
      context.isHarvesting = order === "harvest";
      const crafting = await fromUuid(craft.item);
      if ( crafting ) {
        context.craft = {
          img: crafting.img,
          name: crafting.name,
          contentLink: crafting.toAnchor().outerHTML
        };
      }
    }

    // Cover
    context.coverOptions = Object.entries(CONFIG.DND5E.cover).map(([value, label]) => ({ value, label }));

    // Source Class
    context.spellcastingClasses = Object.entries(this.item.parent?.spellcastingClasses ?? {}).map(([value, cls]) => {
      return { value, label: cls.name };
    });

    return context;
  }

  /* -------------------------------------------- */

  /** @override */
  _getItemOverrides() {
    return [];
  }

  /* -------------------------------------------- */

  /** @override */
  _getItemAdvancementTags(advancement) {
    if ( this.item.isEmbedded && (this._mode !== this.constructor.MODES.EDIT) ) return [];
    const tags = [];
    if ( advancement.classRestriction === "primary" ) {
      tags.push({
        label: "DND5E.AdvancementClassRestrictionPrimary",
        icon: "systems/dnd5e/icons/svg/original-class.svg"
      });
    } else if ( advancement.classRestriction === "secondary" ) {
      tags.push({
        label: "DND5E.AdvancementClassRestrictionSecondary",
        icon: "systems/dnd5e/icons/svg/multiclass.svg"
      });
    }
    return tags;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);

    for ( const control of html[0].querySelectorAll(".tab.advancement [data-context-menu]") ) {
      control.addEventListener("click", event => {
        event.preventDefault();
        event.stopPropagation();
        const { clientX, clientY } = event;
        event.currentTarget.closest("[data-id]").dispatchEvent(new PointerEvent("contextmenu", {
          view: window, bubbles: true, cancelable: true, clientX, clientY
        }));
      });
    }

    html.find(".activities .activity .name").on("click", this._onEditActivity.bind(this));

    if ( this.isEditable ) {
      html.find("button.control-button").on("click", this._onSheetAction.bind(this));
    }

    new ContextMenu5e(html[0], ".advancement-item[data-id]", [], {
      onOpen: target => dnd5e.documents.advancement.Advancement.onContextMenu(this.item, target), jQuery: false
    });
    new ContextMenu5e(html[0], ".activity[data-activity-id]", [], {
      onOpen: target => dnd5e.documents.activity.UtilityActivity.onContextMenu(this.item, target), jQuery: false
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a new recovery profile.
   * @returns {Promise}
   * @protected
   */
  _onAddRecovery() {
    return this.submit({ updateData: { "system.uses.recovery": [...this.item.system.toObject().uses.recovery, {}] } });
  }

  /* -------------------------------------------- */

  /**
   * Delete an activity.
   * @param {HTMLElement} target  The deletion even target.
   * @returns {Promise|void}
   * @protected
   */
  _onDeleteActivity(target) {
    const { activityId } = target.closest("[data-activity-id]").dataset;
    const activity = this.item.system.activities.get(activityId);
    return activity?.deleteDialog();
  }

  /* -------------------------------------------- */

  /**
   * Delete a recovery profile.
   * @param {HTMLElement} target  The deletion event target.
   * @returns {Promise}
   * @protected
   */
  _onDeleteRecovery(target) {
    const recovery = this.item.system.toObject().uses.recovery;
    recovery.splice(target.closest("[data-index]").dataset.index, 1);
    return this.submit({ updateData: { "system.uses.recovery": recovery } });
  }

  /* -------------------------------------------- */

  /**
   * Edit an activity.
   * @param {PointerEvent} event  The triggering event.
   * @returns {Promise|void}
   * @protected
   */
  _onEditActivity(event) {
    const { activityId } = event.currentTarget.closest("[data-activity-id]").dataset;
    const activity = this.item.system.activities.get(activityId);
    return activity?.sheet?.render({ force: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle removing the Item currently being crafted.
   * @returns {Promise}
   * @protected
   */
  _onRemoveCraft() {
    return this.submit({ updateData: { "system.craft": null } });
  }

  /* -------------------------------------------- */

  /**
   * Handle performing some sheet action.
   * @param {PointerEvent} event  The originating event.
   * @returns {Promise|void}
   * @protected
   */
  _onSheetAction(event) {
    const target = event.currentTarget;
    const { action } = target.dataset;
    switch ( action ) {
      case "addRecovery": return this._onAddRecovery();
      case "deleteActivity": return this._onDeleteActivity(target);
      case "deleteRecovery": return this._onDeleteRecovery(target);
      case "removeCraft": return this._onRemoveCraft();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    const expanded = foundry.utils.expandObject(formData);
    if ( foundry.utils.hasProperty(expanded, "uses.recovery") ) {
      formData.uses.recovery = Object.values(formData.uses.recovery);
    }
    return super._updateObject(event, formData);
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @override */
  _onDragStart(event) {
    const { activityId } = event.target.closest(".activity[data-activity-id]")?.dataset ?? {};
    const activity = this.item.system.activities?.get(activityId);
    if ( !activity ) return super._onDragStart(event);
    event.dataTransfer.setData("text/plain", JSON.stringify(activity.toDragData()));
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping an Activity onto the sheet.
   * @param {DragEvent} event       The drag event.
   * @param {object} transfer       The dropped data.
   * @param {object} transfer.data  The Activity data.
   * @protected
   */
  _onDropActivity(event, { data }) {
    const { _id: id, type } = data;
    const source = this.item.system.activities.get(id);

    // Reordering
    if ( source ) {
      const targetId = event.target.closest(".activity[data-activity-id]")?.dataset.activityId;
      const target = this.item.system.activities.get(targetId);
      if ( !target || (target === source) ) return;
      const siblings = this.item.system.activities.filter(a => a._id !== id);
      const sortUpdates = SortingHelpers.performIntegerSort(source, { target, siblings });
      const updateData = Object.fromEntries(sortUpdates.map(({ target, update }) => {
        return [target._id, { sort: update.sort }];
      }));
      this.item.update({ "system.activities": updateData });
    }

    // Copying
    else {
      delete data._id;
      this.item.createActivity(type, data, { renderSheet: false });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping another item onto this item.
   * @param {DragEvent} event  The drag event.
   * @param {object} data      The dropped data.
   */
  async _onDropItem(event, data) {
    const item = await Item.implementation.fromDropData(data);
    if ( (item?.type === "spell") && this.item.system.activities ) this._onDropSpell(event, item);
    else this._onDropAdvancement(event, data);
  }

  /* -------------------------------------------- */

  /**
   * Handle creating a "Cast" activity when dropping a spell.
   * @param {DragEvent} event  The drag event.
   * @param {Item5e} item      The dropped item.
   */
  _onDropSpell(event, item) {
    this.item.createActivity("cast", { spell: { uuid: item.uuid } });
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /**
   * Filter child embedded ActiveEffects based on the current set of filters.
   * @param {string} collection    The embedded collection name.
   * @param {Set<string>} filters  Filters to apply to the children.
   * @returns {Document[]}
   * @protected
   */
  _filterChildren(collection, filters) {
    if ( collection === "effects" ) return Array.from(this.item.effects);
    return [];
  }
}

/**
 * Adds common V2 Actor sheet functionality.
 * @param {typeof ActorSheet5e} Base  The base class being mixed.
 * @returns {typeof ActorSheetV2}
 * @mixin
 */
function ActorSheetV2Mixin(Base) {
  return class ActorSheetV2 extends DocumentSheetV2Mixin(Base) {
    constructor(object, options={}) {
      const key = `${object.type}${object.limited ? ":limited" : ""}`;
      const { width, height } = game.user.getFlag("dnd5e", `sheetPrefs.${key}`) ?? {};
      if ( width && !("width" in options) ) options.width = width;
      if ( height && !("height" in options) ) options.height = height;
      super(object, options);
    }

    /**
     * The cached concentration information for the character.
     * @type {{items: Set<Item5e>, effects: Set<ActiveEffect5e>}}
     * @internal
     */
    _concentration;

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /** @inheritDoc */
    async _renderOuter() {
      const html = await super._renderOuter();
      if ( !game.user.isGM && this.actor.limited ) return html;
      const header = html[0].querySelector(".window-header");

      // Preparation warnings.
      const warnings = document.createElement("a");
      warnings.classList.add("pseudo-header-button", "preparation-warnings");
      warnings.dataset.tooltip = "Warnings";
      warnings.setAttribute("aria-label", game.i18n.localize("Warnings"));
      warnings.innerHTML = '<i class="fas fa-triangle-exclamation"></i>';
      warnings.addEventListener("click", this._onOpenWarnings.bind(this));
      header.querySelector(".window-title").insertAdjacentElement("afterend", warnings);

      // Render tabs.
      const nav = document.createElement("nav");
      nav.classList.add("tabs", "tabs-right");
      nav.dataset.group = "primary";
      nav.append(...this.constructor.TABS.map(({ tab, label, icon, svg }) => {
        const item = document.createElement("a");
        item.classList.add("item", "control");
        item.dataset.group = "primary";
        item.dataset.tab = tab;
        item.dataset.tooltip = label;
        item.setAttribute("aria-label", label);
        if ( icon ) item.innerHTML = `<i class="${icon}"></i>`;
        else if ( svg ) item.innerHTML = `<dnd5e-icon src="systems/dnd5e/icons/svg/${svg}.svg"></dnd5e-icon>`;
        return item;
      }));
      html[0].insertAdjacentElement("afterbegin", nav);
      this._tabs = this.options.tabs.map(t => {
        t.callback = this._onChangeTab.bind(this);
        if ( this._tabs?.[0]?.active !== t.initial ) t.initial = this._tabs?.[0]?.active ?? t.initial;
        return new Tabs5e(t);
      });

      return html;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async _render(force=false, options={}) {
      await super._render(force, options);
      const [warnings] = this.element.find(".pseudo-header-button.preparation-warnings");
      warnings?.toggleAttribute("hidden", !this.actor._preparationWarnings?.length);
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _getHeaderButtons() {
      const buttons = super._getHeaderButtons();
      const tokenButton = buttons.find(b => b.class === "configure-token");
      if ( tokenButton && this.actor.isToken ) tokenButton.icon = "far fa-user-circle";
      return buttons;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    async getData(options) {
      this._concentration = this.actor.concentration; // Cache concentration so it's not called for every item.
      const context = await super.getData(options);
      const activeTab = (game.user.isGM || !this.actor.limited)
        ? this._tabs?.[0]?.active ?? this.options.tabs[0].initial
        : "biography";
      const sheetPrefs = `sheetPrefs.${this.actor.type}.tabs.${activeTab}`;
      context.cssClass += ` tab-${activeTab}`;
      context.sidebarCollapsed = !!game.user.getFlag("dnd5e", `${sheetPrefs}.collapseSidebar`);
      if ( context.sidebarCollapsed ) context.cssClass += " collapsed";
      const { attributes } = this.actor.system;

      // Portrait
      const showTokenPortrait = this.actor.getFlag("dnd5e", "showTokenPortrait") === true;
      const token = this.actor.isToken ? this.actor.token : this.actor.prototypeToken;
      const defaultArtwork = Actor.implementation.getDefaultArtwork(context.source)?.img;
      context.portrait = {
        token: showTokenPortrait,
        src: showTokenPortrait ? token.texture.src : this.actor.img ?? defaultArtwork,
        // TODO: Not sure the best way to update the parent texture from this sheet if this is a token actor.
        path: showTokenPortrait ? this.actor.isToken ? "" : "prototypeToken.texture.src" : "img"
      };

      // Death Saves
      const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
      context.death = {};
      ["success", "failure"].forEach(deathSave => {
        context.death[deathSave] = [];
        for ( let i = 1; i < 4; i++ ) {
          const n = deathSave === "failure" ? i : 4 - i;
          const i18nKey = `DND5E.DeathSave${deathSave.titleCase()}Label`;
          const filled = attributes.death[deathSave] >= n;
          const classes = ["pip"];
          if ( filled ) classes.push("filled");
          if ( deathSave === "failure" ) classes.push("failure");
          context.death[deathSave].push({
            n, filled,
            tooltip: i18nKey,
            label: game.i18n.localize(`${i18nKey}N.${plurals.select(n)}`),
            classes: classes.join(" ")
          });
        }
      });

      // Senses
      context.senses = Object.entries(CONFIG.DND5E.senses).reduce((obj, [k, label]) => {
        const value = attributes.senses[k];
        if ( value ) obj[k] = { label, value };
        return obj;
      }, {});

      if ( attributes.senses.special ) splitSemicolons(attributes.senses.special).forEach((label, i) => {
        context.senses[`custom${i + 1}`] = { label };
      });

      // Containers
      for ( const container of context.containers ?? [] ) {
        const ctx = context.itemContext[container.id];
        ctx.capacity = await container.system.computeCapacity();
        ctx.capacity.maxLabel = Number.isFinite(ctx.capacity.max) ? ctx.capacity.max : "&infin;";
      }

      // Effects & Conditions
      const conditionIds = new Set();
      context.conditions = Object.entries(CONFIG.DND5E.conditionTypes).reduce((arr, [k, c]) => {
        if ( c.pseudo ) return arr; // Filter out pseudo-conditions.
        const { label: name, icon, reference } = c;
        const id = staticID(`dnd5e${k}`);
        conditionIds.add(id);
        const existing = this.actor.effects.get(id);
        const { disabled, img } = existing ?? {};
        arr.push({
          name, reference,
          id: k,
          icon: img ?? icon,
          disabled: existing ? disabled : true
        });
        return arr;
      }, []);

      for ( const category of Object.values(context.effects) ) {
        category.effects = await category.effects.reduce(async (arr, effect) => {
          effect.updateDuration();
          if ( conditionIds.has(effect.id) && !effect.duration.remaining ) return arr;
          const { id, name, img, disabled, duration } = effect;
          const toggleable = !this._concentration?.effects.has(effect);
          let source = await effect.getSource();
          // If the source is an ActiveEffect from another Actor, note the source as that Actor instead.
          if ( source instanceof ActiveEffect ) {
            source = source.target;
            if ( (source instanceof Item) && source.parent && (source.parent !== this.object) ) source = source.parent;
          }
          arr = await arr;
          arr.push({
            id, name, img, disabled, duration, source, toggleable,
            parentId: effect.target === effect.parent ? null : effect.parent.id,
            durationParts: duration.remaining ? duration.label.split(", ") : [],
            hasTooltip: source instanceof dnd5e.documents.Item5e
          });
          return arr;
        }, []);
      }

      context.effects.suppressed.info = context.effects.suppressed.info[0];
      context.flags = this._prepareFlags();
      context.hasConditions = true;
      const sourceVersion = context.system.source?.rules;
      context.modernRules = sourceVersion
        ? sourceVersion === "2024"
        : game.settings.get("dnd5e", "rulesVersion") === "modern";

      return context;
    }

    /* -------------------------------------------- */

    /**
     * Prepare flags displayed in the special traits tab.
     * @returns {object}
     */
    _prepareFlags() {
      const sections = [];
      const source = (this._mode === this.constructor.MODES.PLAY ? this.document : this.document._source);
      const flags = {
        classes: Object.values(this.document.classes)
          .map(cls => ({ value: cls.id, label: cls.name }))
          .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang)),
        data: source.flags?.dnd5e ?? {},
        disabled: this._mode === this.constructor.MODES.PLAY
      };

      // Character Flags
      for ( const [key, config] of Object.entries(CONFIG.DND5E.characterFlags) ) {
        const flag = { ...config, name: `flags.dnd5e.${key}`, value: foundry.utils.getProperty(flags.data, key) };
        const fieldOptions = { label: config.name, hint: config.hint };
        if ( config.type === Boolean ) {
          flag.field = new foundry.data.fields.BooleanField(fieldOptions);
          flag.input = createCheckboxInput;
        }
        else if ( config.type === Number ) flag.field = new foundry.data.fields.NumberField(fieldOptions);
        else flag.field = new foundry.data.fields.StringField(fieldOptions);

        sections[config.section] ??= [];
        sections[config.section].push(flag);
      }

      // Global Bonuses
      const globals = [];
      const addBonus = field => {
        if ( field instanceof foundry.data.fields.SchemaField ) Object.values(field.fields).forEach(f => addBonus(f));
        else globals.push({ field, name: field.fieldPath, value: foundry.utils.getProperty(source, field.fieldPath) });
      };
      addBonus(this.document.system.schema.fields.bonuses);
      if ( globals.length ) sections[game.i18n.localize("DND5E.BONUSES.FIELDS.bonuses.label")] = globals;

      flags.sections = Object.entries(sections).map(([label, fields]) => ({ label, fields }));
      return flags;
    }

    /* -------------------------------------------- */

    /** @override */
    _prepareTraits() {
      const traits = {};
      for ( const [trait$1, config] of Object.entries(CONFIG.DND5E.traits) ) {
        if ( ["dm", "languages"].includes(trait$1) ) continue;
        const key = config.actorKeyPath ?? `system.traits.${trait$1}`;
        const data = foundry.utils.deepClone(foundry.utils.getProperty(this.actor, key));
        if ( !data ) continue;
        let values = data.value;
        if ( !values ) values = [];
        else if ( values instanceof Set ) values = Array.from(values);
        else if ( !Array.isArray(values) ) values = [values];
        values = values.map(key => {
          const value = { key, label: keyLabel(key, { trait: trait$1 }) ?? key };
          const icons = value.icons = [];
          if ( data.bypasses?.size && CONFIG.DND5E.damageTypes[key]?.isPhysical ) icons.push(...data.bypasses.map(p => {
            const type = CONFIG.DND5E.itemProperties[p]?.label;
            return { icon: p, label: game.i18n.format("DND5E.DamagePhysicalBypassesShort", { type }) };
          }));
          return value;
        });
        if ( data.custom ) splitSemicolons(data.custom).forEach(label => values.push({ label }));
        if ( values.length ) traits[trait$1] = values;
      }

      // If petrified, display "All Damage" instead of all damage types separately
      if ( this.document.hasConditionEffect("petrification") ) {
        traits.dr = [{ label: game.i18n.localize("DND5E.DamageAll") }];
      }

      // Combine damage & condition immunities in play mode.
      if ( (this._mode === this.constructor.MODES.PLAY) && traits.ci ) {
        traits.di ??= [];
        traits.di.push(...traits.ci);
        delete traits.ci;
      }

      // Prepare damage modifications
      const dm = this.actor.system.traits?.dm;
      if ( dm ) {
        const rollData = this.actor.getRollData({ deterministic: true });
        const values = Object.entries(dm.amount).map(([k, v]) => {
          const total = simplifyBonus(v, rollData);
          if ( !total ) return null;
          const value = {
            label: `${CONFIG.DND5E.damageTypes[k]?.label ?? k} ${formatNumber(total, { signDisplay: "always" })}`,
            color: total > 0 ? "maroon" : "green"
          };
          const icons = value.icons = [];
          if ( dm.bypasses.size && CONFIG.DND5E.damageTypes[k]?.isPhysical ) icons.push(...dm.bypasses.map(p => {
            const type = CONFIG.DND5E.itemProperties[p]?.label;
            return { icon: p, label: game.i18n.format("DND5E.DamagePhysicalBypassesShort", { type }) };
          }));
          return value;
        }).filter(f => f);
        if ( values.length ) traits.dm = values;
      }

      // Handle languages
      const languages = this.actor.system.traits?.languages?.labels;
      if ( languages?.languages?.length ) traits.languages = languages.languages.map(label => ({ label }));
      for ( const [key, { label }] of Object.entries(CONFIG.DND5E.communicationTypes) ) {
        const data = this.actor.system.traits?.languages?.communication?.[key];
        if ( !data?.value ) continue;
        traits.languages ??= [];
        traits.languages.push({ label, value: data.value });
      }

      // Display weapon masteries
      for ( const key of this.actor.system.traits?.weaponProf?.mastery?.value ?? [] ) {
        let value = traits.weapon?.find(w => w.key === key);
        if ( !value ) {
          value = { key, label: keyLabel(key, { trait: "weapon" }) ?? key, icons: [] };
          traits.weapon ??= [];
          traits.weapon.push(value);
        }
        value.icons.push({ icon: "mastery", label: game.i18n.format("DND5E.WEAPON.Mastery.Label") });
      }

      return traits;
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _prepareItems(context) {
      super._prepareItems(context);

      // Spell slots
      const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
      context.spellbook.forEach(section => {
        section.categories = [
          { activityPartial: "dnd5e.activity-column-school" },
          { activityPartial: "dnd5e.activity-column-time" },
          { activityPartial: "dnd5e.activity-column-range" },
          { activityPartial: "dnd5e.activity-column-target" },
          { activityPartial: "dnd5e.activity-column-roll" },
          { activityPartial: "dnd5e.activity-column-uses" },
          { activityPartial: "dnd5e.activity-column-formula" },
          { activityPartial: "dnd5e.activity-column-controls" }
        ];
        if ( !section.usesSlots ) return;
        const spells = foundry.utils.getProperty(this.actor.system.spells, section.prop);
        const max = spells.override ?? spells.max ?? 0;
        const value = spells.value ?? 0;
        section.pips = Array.fromRange(Math.max(max, value), 1).map(n => {
          const filled = spells.value >= n;
          const temp = n > max;
          const label = temp
            ? game.i18n.localize("DND5E.SpellSlotTemporary")
            : filled
              ? game.i18n.format(`DND5E.SpellSlotN.${plurals.select(n)}`, { n })
              : game.i18n.localize("DND5E.SpellSlotExpended");
          const classes = ["pip"];
          if ( filled ) classes.push("filled");
          if ( temp ) classes.push("tmp");
          return { n, label, filled, tooltip: label, classes: classes.join(" ") };
        });
      });
    }

    /* -------------------------------------------- */

    /** @override */
    _prepareItem(item, ctx) {
      const { system } = item;

      // Spells
      if ( item.type === "spell" ) {
        const linked = item.system.linkedActivity?.item;

        // Activation
        const cost = system.activation?.value ?? "";
        const abbr = {
          action: "DND5E.ActionAbbr",
          bonus: "DND5E.BonusActionAbbr",
          reaction: "DND5E.ReactionAbbr",
          minute: "DND5E.TimeMinuteAbbr",
          hour: "DND5E.TimeHourAbbr",
          day: "DND5E.TimeDayAbbr"
        }[system.activation.type];
        ctx.activation = abbr ? `${cost}${game.i18n.localize(abbr)}` : item.labels.activation;

        // Range
        const units = system.range?.units;
        if ( units && (units !== "none") ) {
          if ( units in CONFIG.DND5E.movementUnits ) {
            ctx.range = {
              distance: true,
              value: system.range.value,
              unit: CONFIG.DND5E.movementUnits[units].abbreviation,
              parts: formatLength(system.range.value, units, { parts: true })
            };
          }
          else ctx.range = { distance: false };
        }

        // Prepared
        const mode = system.preparation?.mode;
        const config = CONFIG.DND5E.spellPreparationModes[mode] ?? {};
        if ( config.prepares && !linked ) {
          const isAlways = mode === "always";
          const prepared = isAlways || system.preparation.prepared;
          ctx.preparation = {
            applicable: true,
            disabled: !item.isOwner || isAlways,
            cls: prepared ? "active" : "",
            icon: `<i class="fa-${prepared ? "solid" : "regular"} fa-${isAlways ? "certificate" : "sun"}"></i>`,
            title: isAlways
              ? CONFIG.DND5E.spellPreparationModes.always.label
              : prepared
                ? CONFIG.DND5E.spellPreparationModes.prepared.label
                : game.i18n.localize("DND5E.SpellUnprepared")
          };
        }
        else ctx.preparation = { applicable: false };

        // Subtitle
        ctx.subtitle = [
          linked ? linked.name : this.actor.classes[system.sourceClass]?.name,
          item.labels.components.vsm
        ].filterJoin(" &bull; ");

        ctx.dataset = {
          itemLevel: item.system.level,
          itemName: item.name,
          itemSort: item.sort,
          itemPreparationMode: item.system.preparation.mode,
          itemPreparationPrepared: item.system.preparation.prepared,
          linkedName: linked?.name
        };
      }

      // Gear
      else {

        // Attuned
        if ( ctx.attunement ) {
          ctx.attunement.applicable = true;
          ctx.attunement.disabled = !item.isOwner;
          ctx.attunement.cls = ctx.attunement.cls === "attuned" ? "active" : "";
        }
        else ctx.attunement = { applicable: false };

        // Equipped
        if ( "equipped" in system ) {
          ctx.equip = {
            applicable: true,
            cls: system.equipped ? "active" : "",
            title: `DND5E.${system.equipped ? "Equipped" : "Unequipped"}`,
            disabled: !item.isOwner
          };
        }
        else ctx.equip = { applicable: false };

        // Subtitles
        ctx.subtitle = [system.type?.label, item.isActive ? item.labels.activation : null].filterJoin(" &bull; ");
      }

      // Concentration
      if ( this._concentration.items.has(item) ) ctx.concentration = true;

      // To Hit
      const toHit = parseInt(item.labels.modifier);
      ctx.toHit = item.hasAttack && !isNaN(toHit) ? toHit : null;

      // Save
      ctx.save = { ...item.system.activities?.getByType("save")[0]?.save };
      ctx.save.ability = ctx.save.ability?.size ? ctx.save.ability.size === 1
        ? CONFIG.DND5E.abilities[ctx.save.ability.first()]?.abbreviation
        : game.i18n.localize("DND5E.AbbreviationDC") : null;

      // Activities
      ctx.activities = item.system.activities
        ?.filter(a => !item.getFlag("dnd5e", "riders.activity")?.includes(a.id))
        ?.map(this._prepareActivity.bind(this));

      // Linked Uses
      const cachedFor = fromUuidSync(item.flags.dnd5e?.cachedFor, { relative: this.actor, strict: false });
      if ( cachedFor ) ctx.linkedUses = cachedFor.consumption?.targets.find(t => t.type === "activityUses")
        ? cachedFor.uses : cachedFor.consumption?.targets.find(t => t.type === "itemUses")
          ? cachedFor.item.system.uses : null;
    }

    /* -------------------------------------------- */

    /**
     * Prepare activity data.
     * @param {Activity} activity  The activity.
     * @returns {object}
     * @protected
     */
    _prepareActivity(activity) {
      let { _id, activation, img, labels, name, range, save, uses } = activity;

      // To Hit
      const toHit = parseInt(labels.toHit);

      // Activation
      const activationAbbr = {
        action: "DND5E.ActionAbbr",
        bonus: "DND5E.BonusActionAbbr",
        reaction: "DND5E.ReactionAbbr",
        minute: "DND5E.TimeMinuteAbbr",
        hour: "DND5E.TimeHourAbbr",
        day: "DND5E.TimeDayAbbr"
      }[activation?.type || ""];

      // Limited Uses
      uses = { ...(uses ?? {}) };
      uses.hasRecharge = uses.max && (uses.recovery?.[0]?.period === "recharge");
      uses.isOnCooldown = uses.hasRecharge && (uses.value < 1);

      return {
        _id, labels, name, range, uses,
        activation: activationAbbr
          ? `${activation.value ?? ""}${game.i18n.localize(activationAbbr)}`
          : labels.activation,
        icon: {
          src: img,
          svg: img.endsWith(".svg")
        },
        isSpell: activity.item.type === "spell",
        save: save ? {
          ...save,
          ability: save.ability?.size
            ? save.ability.size === 1
              ? CONFIG.DND5E.abilities[save.ability.first()]?.abbreviation
              : game.i18n.localize("DND5E.AbbreviationDC")
            : null
        } : null,
        toHit: isNaN(toHit) ? null : toHit
      };
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _getLabels() {
      const labels = super._getLabels();
      labels.damageAndHealing = { ...CONFIG.DND5E.damageTypes, ...CONFIG.DND5E.healingTypes };
      return labels;
    }

    /* -------------------------------------------- */
    /*  Event Listeners & Handlers                  */
    /* -------------------------------------------- */

    /** @inheritDoc */
    activateListeners(html) {
      super.activateListeners(html);
      html.find(".pips[data-prop]").on("click", this._onTogglePip.bind(this));
      html.find(".rollable:is(.saving-throw, .ability-check)").on("click", this._onRollAbility.bind(this));
      html.find(".sidebar-collapser").on("click", this._onToggleSidebar.bind(this));
      html.find("[data-item-id][data-action]").on("click", this._onItemAction.bind(this));
      html.find("dialog.warnings").on("click", this._onCloseWarnings.bind(this));
      this.form.querySelectorAll("[data-reference-tooltip]").forEach(this._applyReferenceTooltips.bind(this));

      // Prevent default middle-click scrolling when locking a tooltip.
      this.form.addEventListener("pointerdown", event => {
        if ( (event.button === 1) && document.getElementById("tooltip")?.classList.contains("active") ) {
          event.preventDefault();
        }
      });

      if ( this.isEditable ) {
        html.find(".meter > .hit-points").on("click", event => this._toggleEditHP(event, true));
        html.find(".meter > .hit-points > input").on("blur", event => this._toggleEditHP(event, false));
      }

      // Play mode only.
      if ( this._mode === this.constructor.MODES.PLAY ) {
        html.find(".portrait").on("click", this._onShowPortrait.bind(this));
      }
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onChangeTab(event, tabs, active) {
      super._onChangeTab(event, tabs, active);
      const sheetPrefs = `sheetPrefs.${this.actor.type}.tabs.${active}`;
      const sidebarCollapsed = game.user.getFlag("dnd5e", `${sheetPrefs}.collapseSidebar`);
      if ( sidebarCollapsed !== undefined ) this._toggleSidebar(sidebarCollapsed);
      const createChild = this.form.querySelector(".create-child");
      createChild.setAttribute("aria-label", game.i18n.format("SIDEBAR.Create", {
        type: game.i18n.localize(`DOCUMENT.${active === "effects" ? "ActiveEffect" : "Item"}`)
      }));
    }

    /* -------------------------------------------- */

    /**
     * Handle closing the warnings dialog.
     * @param {PointerEvent} event  The triggering event.
     * @protected
     */
    _onCloseWarnings(event) {
      if ( event.target instanceof HTMLDialogElement ) event.target.close();
      if ( event.target instanceof HTMLAnchorElement ) event.target.closest("dialog")?.close();
    }

    /* -------------------------------------------- */

    /** @override */
    _onCreateChild() {
      const activeTab = this._tabs?.[0]?.active ?? this.options.tabs[0].initial;

      if ( activeTab === "effects" ) return ActiveEffect.implementation.create({
        name: game.i18n.localize("DND5E.EffectNew"),
        icon: "icons/svg/aura.svg"
      }, { parent: this.actor, renderSheet: true });

      if ( activeTab === "spells" ) return Item.implementation.create({
        name: game.i18n.format("DOCUMENT.New", { type: game.i18n.format(CONFIG.Item.typeLabels.spell) }),
        type: "spell",
        img: Item.implementation.getDefaultArtwork({ type: "spell" })?.img ?? Item.implementation.DEFAULT_ICON
      }, { parent: this.actor, renderSheet: true });

      const features = ["feat", "race", "background", "class", "subclass"];
      if ( this.actor.type === "npc" ) features.push("weapon");

      let types = {
        features,
        inventory: ["weapon", "equipment", "consumable", "tool", "container", "loot"]
      }[activeTab] ?? [];

      types = types.filter(type => {
        const model = CONFIG.Item.dataModels[type];
        return !model.metadata?.singleton || !this.actor.itemTypes[type].length;
      });

      if ( types.length ) return Item.implementation.createDialog({}, {
        parent: this.actor, pack: this.actor.pack, types
      });
    }

    /* -------------------------------------------- */

    /**
     * Handling beginning a drag-drop operation on an Activity.
     * @param {DragEvent} event  The originating drag event.
     * @protected
     */
    _onDragActivity(event) {
      const { itemId } = event.target.closest("[data-item-id]").dataset;
      const { activityId } = event.target.closest("[data-activity-id]").dataset;
      const activity = this.actor.items.get(itemId)?.system.activities?.get(activityId);
      if ( activity ) event.dataTransfer.setData("text/plain", JSON.stringify(activity.toDragData()));
    }

    /* -------------------------------------------- */

    /**
     * Handle beginning a drag-drop operation on an Item.
     * @param {DragEvent} event  The originating drag event.
     * @protected
     */
    _onDragItem(event) {
      const { itemId } = event.target.closest("[data-item-id]").dataset;
      const item = this.actor.items.get(itemId);
      if ( item ) event.dataTransfer.setData("text/plain", JSON.stringify(item.toDragData()));
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onDragStart(event) {
      // Add another deferred deactivation to catch the second pointerenter event that seems to be fired on Firefox.
      requestAnimationFrame(() => game.tooltip.deactivate());
      game.tooltip.deactivate();

      if ( event.target.matches("[data-item-id] > .item-row") ) return this._onDragItem(event);
      else if ( event.target.matches("[data-item-id] [data-activity-id], [data-item-id][data-activity-id]") ) {
        return this._onDragActivity(event);
      }
      return super._onDragStart(event);
    }

    /* -------------------------------------------- */

    /**
     * Handle performing some action on an owned Item.
     * @param {PointerEvent} event  The triggering event.
     * @protected
     */
    _onItemAction(event) {
      if ( event.target.closest("select") ) return;
      event.preventDefault();
      event.stopPropagation();
      const itemId = event.currentTarget.closest("[data-item-id]")?.dataset.itemId;
      const action = event.currentTarget.dataset.action;
      const item = this.actor.items.get(itemId);

      switch ( action ) {
        case "delete": item?.deleteDialog(); break;
        case "edit": item?.sheet.render(true, { mode: ItemSheet5e2.MODES.EDIT }); break;
        case "view": item?.sheet.render(true, { mode: ItemSheet5e2.MODES.PLAY }); break;
      }
    }

    /* -------------------------------------------- */

    /**
     * Handle opening the warnings dialog.
     * @param {PointerEvent} event  The triggering event.
     * @protected
     */
    _onOpenWarnings(event) {
      event.stopImmediatePropagation();
      const { top, left, height } = event.target.getBoundingClientRect();
      const { clientWidth } = document.documentElement;
      const dialog = this.form.querySelector("dialog.warnings");
      Object.assign(dialog.style, { top: `${top + height}px`, left: `${Math.min(left - 16, clientWidth - 300)}px` });
      dialog.showModal();
    }

    /* -------------------------------------------- */

    /**
     * Toggle editing hit points.
     * @param {PointerEvent} event  The triggering event.
     * @param {boolean} edit        Whether to toggle to the edit state.
     * @protected
     */
    _toggleEditHP(event, edit) {
      const target = event.currentTarget.closest(".hit-points");
      const label = target.querySelector(":scope > .label");
      const input = target.querySelector(":scope > input");
      label.hidden = edit;
      input.hidden = !edit;
      if ( edit ) input.focus();
    }

    /* -------------------------------------------- */

    /** @inheritDoc */
    _onResize(event) {
      super._onResize(event);
      const { width, height } = this.position;
      const key = `${this.actor.type}${this.actor.limited ? ":limited": ""}`;
      game.user.setFlag("dnd5e", `sheetPrefs.${key}`, { width, height });
    }

    /* -------------------------------------------- */

    /**
     * Handle showing the character's portrait or token art.
     * @protected
     */
    _onShowPortrait() {
      const showTokenPortrait = this.actor.getFlag("dnd5e", "showTokenPortrait") === true;
      const token = this.actor.isToken ? this.actor.token : this.actor.prototypeToken;
      const img = showTokenPortrait ? token.texture.src : this.actor.img;
      new ImagePopout(img, { title: this.actor.name, uuid: this.actor.uuid }).render(true);
    }

    /* -------------------------------------------- */

    /**
     * Handle toggling a pip on the character sheet.
     * @param {PointerEvent} event  The triggering event.
     * @returns {Promise<Actor5e>|void}
     * @protected
     */
    _onTogglePip(event) {
      const n = Number(event.target.closest("[data-n]")?.dataset.n);
      if ( !n || isNaN(n) ) return;
      const prop = event.currentTarget.dataset.prop;
      let value = foundry.utils.getProperty(this.actor, prop);
      if ( value === n ) value--;
      else value = n;
      return this.actor.update({ [prop]: value });
    }

    /* -------------------------------------------- */

    /**
     * Handle the user toggling the sidebar collapsed state.
     * @protected
     */
    _onToggleSidebar() {
      const collapsed = this._toggleSidebar();
      const activeTab = this._tabs?.[0]?.active ?? "details";
      game.user.setFlag("dnd5e", `sheetPrefs.${this.actor.type}.tabs.${activeTab}.collapseSidebar`, collapsed);
    }

    /* -------------------------------------------- */

    /**
     * Toggle the sidebar collapsed state.
     * @param {boolean} [collapsed]  Force a particular collapsed state.
     * @returns {boolean}            The new collapsed state.
     * @protected
     */
    _toggleSidebar(collapsed) {
      this.form.classList.toggle("collapsed", collapsed);
      collapsed = this.form.classList.contains("collapsed");
      const collapser = this.form.querySelector(".sidebar-collapser");
      const icon = collapser.querySelector("i");
      collapser.dataset.tooltip = `JOURNAL.View${collapsed ? "Expand" : "Collapse"}`;
      collapser.setAttribute("aria-label", game.i18n.localize(collapser.dataset.tooltip));
      icon.classList.remove("fa-caret-left", "fa-caret-right");
      icon.classList.add(`fa-caret-${collapsed ? "right" : "left"}`);
      return collapsed;
    }

    /* -------------------------------------------- */

    /**
     * Handle rolling an ability check or saving throw.
     * @param {PointerEvent} event  The triggering event.
     * @protected
     */
    _onRollAbility(event) {
      const ability = event.currentTarget.closest("[data-ability]").dataset.ability;
      const isSavingThrow = event.currentTarget.classList.contains("saving-throw");
      if ( ability === "concentration" ) this.actor.rollConcentration({ event, legacy: false });
      else if ( isSavingThrow ) this.actor.rollSavingThrow({ ability, event });
      else this.actor.rollAbilityCheck({ ability, event });
    }

    /* -------------------------------------------- */

    /**
     * Initialize a rule tooltip on an element.
     * @param {HTMLElement} element  The tooltipped element.
     * @protected
     */
    _applyReferenceTooltips(element) {
      if ( "tooltip" in element.dataset ) return;
      const uuid = element.dataset.referenceTooltip;
      element.dataset.tooltip = `
        <section class="loading" data-uuid="${uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>
      `;
    }

    /* -------------------------------------------- */
    /*  Helpers                                     */
    /* -------------------------------------------- */

    /**
     * Can an item be expanded on the sheet?
     * @param {Item5e} item  Item on the sheet.
     * @returns {boolean}
     */
    canExpand(item) {
      return !["class", "subclass"].includes(item.type);
    }
  };
}

/**
 * An Actor sheet for player character type actors.
 * @mixes ActorSheetV2
 */
class ActorSheet5eCharacter2 extends ActorSheetV2Mixin(ActorSheet5eCharacter) {
  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e2", "sheet", "actor", "character", "vertical-tabs"],
      tabs: [{ navSelector: ".tabs", contentSelector: ".tab-body", initial: "details" }],
      dragDrop: [
        { dragSelector: ".item-list .item > .item-row", dropSelector: null },
        { dragSelector: ".item-list .item .activity-row", dropSelector: null },
        { dragSelector: ".containers .container", dropSelector: null },
        { dragSelector: ".favorites :is([data-item-id], [data-effect-id])", dropSelector: null },
        { dragSelector: ":is(.race, .background)[data-item-id]", dropSelector: null },
        { dragSelector: ".classes .gold-icon[data-item-id]", dropSelector: null },
        { dragSelector: "[data-key] .skill-name, [data-key] .tool-name", dropSelector: null },
        { dragSelector: ".spells-list .spell-header, .slots[data-favorite-id]", dropSelector: null },
        { dragSelector: ".effects-list [data-effect-id]", dropSelector: null }
      ],
      scrollY: [".main-content"],
      width: 800,
      height: 1000,
      resizable: true
    });
  }

  /**
   * Proficiency class names.
   * @enum {string}
   */
  static PROFICIENCY_CLASSES = {
    0: "none",
    0.5: "half",
    1: "full",
    2: "double"
  };

  /** @override */
  static TABS = [
    { tab: "details", label: "DND5E.Details", icon: "fas fa-cog" },
    { tab: "inventory", label: "DND5E.Inventory", svg: "backpack" },
    { tab: "features", label: "DND5E.Features", icon: "fas fa-list" },
    { tab: "spells", label: "TYPES.Item.spellPl", icon: "fas fa-book" },
    { tab: "effects", label: "DND5E.Effects", icon: "fas fa-bolt" },
    { tab: "biography", label: "DND5E.Biography", icon: "fas fa-feather" },
    { tab: "bastion", label: "DND5E.Bastion.Label", icon: "fas fa-chess-rook" },
    { tab: "special-traits", label: "DND5E.SpecialTraits", icon: "fas fa-star" }
  ];

  /**
   * Whether the user has manually opened the death save tray.
   * @type {boolean}
   * @protected
   */
  _deathTrayOpen = false;

  /* -------------------------------------------- */

  /** @override */
  get template() {
    if ( !game.user.isGM && this.actor.limited ) return "systems/dnd5e/templates/actors/limited-sheet-2.hbs";
    return "systems/dnd5e/templates/actors/character-sheet-2.hbs";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _render(force=false, options={}) {
    await super._render(force, options);
    if ( !this.rendered ) return;
    const context = options.renderContext ?? options.action;
    const data = options.renderData ?? options.data;
    const isUpdate = (context === "update") || (context === "updateActor");
    const hp = foundry.utils.getProperty(data ?? {}, "system.attributes.hp.value");
    if ( isUpdate && (hp === 0) ) this._toggleDeathTray(true);
    this._toggleBastionTab();
  }

  /* -------------------------------------------- */

  /**
   * Handle displaying the bastion tab when characters are eligible.
   * @protected
   */
  _toggleBastionTab() {
    const [bastion] = this.element.find('nav.tabs [data-tab="bastion"]');
    const { enabled } = game.settings.get("dnd5e", "bastionConfiguration");
    const { basic, special } = CONFIG.DND5E.facilities.advancement;
    const threshold = Math.min(...Object.keys(basic), ...Object.keys(special));
    if ( bastion ) bastion.toggleAttribute("hidden", (this.actor.system.details.level < threshold) || !enabled);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = await super.getData(options);
    const { attributes, bastion, details, traits } = this.actor.system;

    // Class
    context.labels.class = Object.values(this.actor.classes).sort((a, b) => {
      return b.system.levels - a.system.levels;
    }).map(c => `${c.name} ${c.system.levels}`).join(" / ");
    context.showClassDrop = !context.labels.class || (this._mode === this.constructor.MODES.EDIT);

    // Exhaustion
    if ( CONFIG.DND5E.conditionTypes.exhaustion ) {
      const max = CONFIG.DND5E.conditionTypes.exhaustion.levels;
      context.exhaustion = Array.fromRange(max, 1).reduce((acc, n) => {
        const label = game.i18n.format("DND5E.ExhaustionLevel", { n });
        const classes = ["pip"];
        const filled = attributes.exhaustion >= n;
        if ( filled ) classes.push("filled");
        if ( n === max ) classes.push("death");
        const pip = { n, label, filled, tooltip: label, classes: classes.join(" ") };

        if ( n <= max / 2 ) acc.left.push(pip);
        else acc.right.push(pip);
        return acc;
      }, { left: [], right: [] });
    }

    // Speed
    context.speed = Object.entries(CONFIG.DND5E.movementTypes).reduce((obj, [k, label]) => {
      const value = attributes.movement[k];
      if ( value > obj.value ) Object.assign(obj, { value, label });
      return obj;
    }, { value: 0, label: CONFIG.DND5E.movementTypes.walk });

    // Death Saves
    context.death.open = this._deathTrayOpen;

    // Ability Scores
    context.abilityRows = Object.entries(context.abilities).reduce((obj, [k, ability]) => {
      ability.key = k;
      ability.abbr = CONFIG.DND5E.abilities[k]?.abbreviation ?? "";
      ability.baseValue = context.source.abilities[k]?.value ?? 0;
      if ( obj.bottom.length > 5 ) obj.top.push(ability);
      else obj.bottom.push(ability);
      return obj;
    }, { top: [], bottom: [] });
    context.abilityRows.optional = Object.keys(CONFIG.DND5E.abilities).length - 6;

    // Saving Throws
    context.saves = {};
    for ( let ability of Object.values(context.abilities) ) {
      ability = context.saves[ability.key] = { ...ability };
      ability.class = this.constructor.PROFICIENCY_CLASSES[context.editable ? ability.baseProf : ability.proficient];
      ability.hover = CONFIG.DND5E.proficiencyLevels[ability.proficient];
    }

    if ( this.actor.statuses.has(CONFIG.specialStatusEffects.CONCENTRATING) || context.editable ) {
      context.saves.concentration = {
        isConcentration: true,
        class: "colspan concentration",
        label: game.i18n.localize("DND5E.Concentration"),
        abbr: game.i18n.localize("DND5E.Concentration"),
        save: attributes.concentration.save
      };
    }

    // Size
    context.size = {
      label: CONFIG.DND5E.actorSizes[traits.size]?.label ?? traits.size,
      abbr: CONFIG.DND5E.actorSizes[traits.size]?.abbreviation ?? "—",
      mod: attributes.encumbrance.mod
    };

    // Skills & Tools
    for ( const [key, entry] of Object.entries(context.skills).concat(Object.entries(context.tools)) ) {
      entry.class = this.constructor.PROFICIENCY_CLASSES[context.editable ? entry.baseValue : entry.value];
      if ( key in CONFIG.DND5E.skills ) entry.reference = CONFIG.DND5E.skills[key].reference;
      else if ( key in CONFIG.DND5E.tools ) entry.reference = getBaseItemUUID(CONFIG.DND5E.tools[key].id);
    }

    // Character Background
    context.creatureType = {
      class: details.type.value === "custom" ? "none" : "",
      icon: CONFIG.DND5E.creatureTypes[details.type.value]?.icon ?? "icons/svg/mystery-man.svg",
      title: details.type.value === "custom"
        ? details.type.custom
        : CONFIG.DND5E.creatureTypes[details.type.value]?.label,
      reference: CONFIG.DND5E.creatureTypes[details.type.value]?.reference,
      subtitle: details.type.subtype
    };

    if ( details.race instanceof dnd5e.documents.Item5e ) context.race = details.race;
    if ( details.background instanceof dnd5e.documents.Item5e ) context.background = details.background;

    // Senses
    if ( foundry.utils.isEmpty(context.senses) ) delete context.senses;

    // Spellcasting
    context.spellcasting = [];
    for ( const item of Object.values(this.actor.classes).sort((a, b) => b.system.levels - a.system.levels) ) {
      const sc = item.spellcasting;
      if ( !sc?.progression || (sc.progression === "none") ) continue;
      const ability = this.actor.system.abilities[sc.ability];
      const mod = ability?.mod ?? 0;
      const name = item.system.spellcasting.progression === sc.progression ? item.name : item.subclass?.name;
      context.spellcasting.push({
        label: game.i18n.format("DND5E.SpellcastingClass", { class: name }),
        ability: { mod, ability: sc.ability },
        attack: sc.attack,
        preparation: sc.preparation,
        primary: this.actor.system.attributes.spellcasting === sc.ability,
        save: sc.save
      });
    }

    // Characteristics
    context.characteristics = [
      "alignment", "eyes", "height", "faith", "hair", "weight", "gender", "skin", "age"
    ].map(k => {
      const fields = CharacterData.schema.fields.details.fields;
      const field = fields[k];
      const name = `system.details.${k}`;
      return { name, label: field.label, value: foundry.utils.getProperty(this.actor, name) ?? "" };
    });

    // Favorites
    context.favorites = await this._prepareFavorites();
    context.favorites.sort((a, b) => a.sort - b.sort);

    // Epic Boons
    if ( context.system.details.xp.boonsEarned !== undefined ) {
      const pluralRules = new Intl.PluralRules(game.i18n.lang);
      context.epicBoonsEarned = game.i18n.format(
        `DND5E.ExperiencePoints.Boons.${pluralRules.select(context.system.details.xp.boonsEarned ?? 0)}`,
        { number: formatNumber(context.system.details.xp.boonsEarned ?? 0, { signDisplay: "always" }) }
      );
    }

    // Bastion
    context.bastion = {
      description: await TextEditor.enrichHTML(bastion.description, {
        secrets: this.actor.isOwner,
        rollData: context.rollData,
        relativeTo: this.actor
      })
    };

    // Facilities
    await this._prepareFacilities(context);

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareItems(context) {
    super._prepareItems(context);
    context.containers = context.inventory
      .findSplice(entry => entry.dataset.type === "container")
      ?.items?.sort((a, b) => a.sort - b.sort);
    context.inventory = context.inventory.filter(entry => entry.items.length);
    context.inventory.push({ label: "DND5E.Contents", items: [], dataset: { type: "all" } });
    context.inventory.forEach(section => {
      section.categories = [
        { activityPartial: "dnd5e.activity-column-price" },
        { activityPartial: "dnd5e.activity-column-weight" },
        { activityPartial: "dnd5e.activity-column-quantity" },
        { activityPartial: "dnd5e.activity-column-uses" },
        { activityPartial: "dnd5e.activity-column-controls" }
      ];
    });

    // Remove races & background as they are shown on the details tab instead.
    const features = context.features.filter(f => (f.dataset.type !== "background") && (f.dataset.type !== "race"));
    features.forEach(f => {
      if ( f.hasActions ) f.dataset.type = "active";
      else f.dataset.type = "passive";
    });

    // Add extra categories for features grouping.
    Object.values(this.actor.classes ?? {}).sort((a, b) => b.system.levels - a.system.levels).forEach(cls => {
      features.push({
        label: game.i18n.format("DND5E.FeaturesClass", { class: cls.name }),
        items: [],
        dataset: { type: cls.identifier }
      });
    });

    if ( this.actor.system.details.race instanceof dnd5e.documents.Item5e ) {
      features.push({ label: "DND5E.Species.Features", items: [], dataset: { type: "race" } });
    }

    if ( this.actor.system.details.background instanceof dnd5e.documents.Item5e ) {
      features.push({ label: "DND5E.FeaturesBackground", items: [], dataset: { type: "background" } });
    }

    features.push({ label: "DND5E.FeaturesOther", items: [], dataset: { type: "other" } });
    context.classes = features.findSplice(f => f.isClass)?.items;

    context.features = {
      sections: features,
      filters: [
        { key: "action", label: "DND5E.Action" },
        { key: "bonus", label: "DND5E.BonusAction" },
        { key: "reaction", label: "DND5E.Reaction" },
        { key: "sr", label: "DND5E.REST.Short.Label" },
        { key: "lr", label: "DND5E.REST.Long.Label" },
        { key: "concentration", label: "DND5E.Concentration" },
        { key: "mgc", label: "DND5E.Item.Property.Magical" }
      ]
    };

    // TODO: Customise this per-section.
    features.forEach(section => {
      section.categories = [
        {
          classes: "item-uses", label: "DND5E.Uses", itemPartial: "dnd5e.column-uses",
          activityPartial: "dnd5e.activity-column-uses"
        },
        {
          classes: "item-recovery", label: "DND5E.Recovery", itemPartial: "dnd5e.column-recovery",
          activityPartial: "dnd5e.activity-column-recovery"
        },
        {
          classes: "item-controls", itemPartial: "dnd5e.column-feature-controls",
          activityPartial: "dnd5e.activity-column-controls"
        }
      ];
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".death-tab").on("click", () => this._toggleDeathTray());
    html.find("[data-action]").on("click", this._onAction.bind(this));

    // Apply special context menus for items outside inventory elements
    const featuresElement = html[0].querySelector(`[data-tab="features"] ${this.options.elements.inventory}`);
    if ( featuresElement ) new ContextMenu5e(
      html[0], ".pills-lg [data-item-id], .favorites [data-item-id], .facility[data-item-id]", [],
      { onOpen: (...args) => featuresElement._onOpenContextMenu(...args), jQuery: true }
    );

    // Edit mode only.
    if ( this._mode === this.constructor.MODES.EDIT ) {
      html.find(".tab.details .item-action").on("click", this._onItemAction.bind(this));
    }

    if ( !this.isEditable ) {
      html[0].querySelectorAll('[data-action="findItem"]').forEach(el => el.classList.add("disabled"));
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _disableOverriddenFields(html) {
    // When in edit mode, field values will be the base value, rather than the derived value, so it should not be
    // necessary to disable them anymore.
  }

  /* -------------------------------------------- */

  /** @override */
  _getSubmitData(updateData={}) {
    // Skip over ActorSheet#_getSubmitData to allow for editing overridden values.
    return FormApplication.prototype._getSubmitData.call(this, updateData);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async activateEditor(name, options={}, initialContent="") {
    options.relativeLinks = true;
    options.plugins = {
      menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
        compact: true,
        destroyOnSave: false,
        onSave: () => this.saveEditor(name, { remove: false })
      })
    };
    return super.activateEditor(name, options, initialContent);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    const modes = CONFIG.DND5E.spellPreparationModes;
    const { key } = event.target.closest("[data-key]")?.dataset ?? {};
    const { level, preparationMode } = event.target.closest("[data-level]")?.dataset ?? {};
    const isSlots = event.target.closest("[data-favorite-id]") || event.target.classList.contains("spell-header");
    let type;
    if ( key in CONFIG.DND5E.skills ) type = "skill";
    else if ( key in CONFIG.DND5E.tools ) type = "tool";
    else if ( modes[preparationMode]?.upcast && (level !== "0") && isSlots ) type = "slots";
    if ( !type ) return super._onDragStart(event);

    // Add another deferred deactivation to catch the second pointerenter event that seems to be fired on Firefox.
    requestAnimationFrame(() => game.tooltip.deactivate());
    game.tooltip.deactivate();

    const dragData = { dnd5e: { action: "favorite", type } };
    if ( type === "slots" ) dragData.dnd5e.id = (preparationMode === "prepared") ? `spell${level}` : preparationMode;
    else dragData.dnd5e.id = key;
    event.dataTransfer.setData("application/json", JSON.stringify(dragData));
    event.dataTransfer.effectAllowed = "link";
  }

  /* -------------------------------------------- */

  /**
   * Toggle the death save tray.
   * @param {boolean} [open]  Force a particular open state.
   * @protected
   */
  _toggleDeathTray(open) {
    const tray = this.form.querySelector(".death-tray");
    const tab = tray.querySelector(".death-tab");
    tray.classList.toggle("open", open);
    this._deathTrayOpen = tray.classList.contains("open");
    tab.dataset.tooltip = `DND5E.DeathSave${this._deathTrayOpen ? "Hide" : "Show"}`;
    tab.setAttribute("aria-label", game.i18n.localize(tab.dataset.tooltip));
  }

  /* -------------------------------------------- */

  /**
   * Handle the user performing some sheet action.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onAction(event) {
    const target = event.currentTarget;
    switch ( target.dataset.action ) {
      case "addFacility": this._onAddFacility(event); break;
      case "deleteOccupant": this._onDeleteOccupant(event); break;
      case "findItem":
        this._onFindItem(target.dataset.itemType, { classIdentifier: target.dataset.classIdentifier });
        break;
      case "removeFavorite": this._onRemoveFavorite(event); break;
      case "spellcasting": this._onToggleSpellcasting(event); break;
      case "toggleInspiration": this._onToggleInspiration(); break;
      case "useFacility": this._onUseFacility(event); break;
      case "useFavorite": this._onUseFavorite(event); break;
      case "viewOccupant": this._onViewOccupant(event); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle prompting the user to add a new facility.
   * @param {PointerEvent} event  The triggering event.
   * @returns {Promise<void>}
   * @protected
   */
  async _onAddFacility(event) {
    const { type } = event.target.closest("[data-type]").dataset;
    const otherType = type === "basic" ? "special" : "basic";
    const result = await CompendiumBrowser.selectOne({
      filters: { locked: {
        types: new Set(["facility"]),
        additional: { type: { [type]: 1, [otherType]: -1 }, level: { max: this.actor.system.details.level } }
      } }
    });
    if ( result ) this._onDropItemCreate(await fromUuid(result));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeInput(event) {
    const { name } = event.target.dataset;
    const { itemId } = event.target.closest("[data-item-id]")?.dataset ?? {};
    const item = this.actor.items.get(itemId);
    if ( event.target.closest(".favorites") && name && item ) return item.update({ [name]: event.target.value });
    return super._onChangeInput(event);
  }

  /* -------------------------------------------- */

  /** @override */
  _onConfigureSheet(event) {
    event.preventDefault();
    new SheetConfig5e(this.document, {
      top: this.position.top + 40,
      left: this.position.left + ((this.position.width - DocumentSheet.defaultOptions.width) / 2)
    }).render(true);
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting an occupant from a facility.
   * @param {PointerEvent} event  The triggering event.
   * @returns {Promise<Item5e>|void}
   * @protected
   */
  _onDeleteOccupant(event) {
    event.stopPropagation();
    const { facilityId } = event.target.closest("[data-facility-id]")?.dataset ?? {};
    const { prop } = event.target.closest("[data-prop]")?.dataset ?? {};
    const { index } = event.target.closest("[data-index]")?.dataset ?? {};
    const facility = this.actor.items.get(facilityId);
    if ( !facility || !prop || (index === undefined) ) return;
    let { value } = foundry.utils.getProperty(facility, prop);
    value = value.filter((_, i) => i !== Number(index));
    return facility.update({ [`${prop}.value`]: value });
  }

  /* -------------------------------------------- */

  /**
   * Show available items of a given type.
   * @param {string} type                       The item type.
   * @param {object} [options={}]
   * @param {string} [options.classIdentifier]  Identifier of the class when finding a subclass.
   * @protected
   */
  async _onFindItem(type, { classIdentifier }={}) {
    if ( !this.isEditable ) return;
    const filters = { locked: { types: new Set([type]) } };
    if ( classIdentifier ) filters.locked.additional = { class: { [classIdentifier]: 1 } };
    if ( type === "class" ) {
      const existingIdentifiers = new Set(Object.keys(this.actor.classes));
      filters.locked.arbitrary = [{ o: "NOT", v: { k: "system.identifier", o: "in", v: existingIdentifiers } }];
    }
    const result = await CompendiumBrowser.selectOne({ filters });
    if ( result ) this._onDropItemCreate(game.items.fromCompendium(await fromUuid(result), { keepId: true }));
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling inspiration.
   * @protected
   */
  _onToggleInspiration() {
    this.actor.update({ "system.attributes.inspiration": !this.actor.system.attributes.inspiration });
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the character's primary spellcasting ability.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onToggleSpellcasting(event) {
    const ability = event.currentTarget.closest("[data-ability]")?.dataset.ability;
    this.actor.update({ "system.attributes.spellcasting": ability });
  }

  /* -------------------------------------------- */

  /**
   * Handle viewing a facility occupant.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  async _onViewOccupant(event) {
    const { actorUuid } = event.currentTarget.dataset;
    const actor = await fromUuid(actorUuid);
    actor?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _filterItem(item) {
    if ( item.type === "container" ) return true;
  }

  /* -------------------------------------------- */
  /*  Favorites                                   */
  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( data.dnd5e?.action === "favorite" || (["Activity", "Item"].includes(data.type)
      && event.target.closest(".favorites")) ) return "link";
    return super._defaultDropBehavior(event, data);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    if ( !event.target.closest(".favorites") ) return super._onDrop(event);
    const dragData = event.dataTransfer.getData("application/json") || event.dataTransfer.getData("text/plain");
    if ( !dragData ) return super._onDrop(event);
    let data;
    try {
      data = JSON.parse(dragData);
    } catch(e) {
      console.error(e);
      return;
    }
    const { action, type, id } = data.dnd5e ?? {};
    if ( action === "favorite" ) return this._onDropFavorite(event, { type, id });
    if ( data.type === "Activity" ) return this._onDropActivity(event, data);
    return super._onDrop(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle dropping an Activity onto the sheet.
   * @param {DragEvent} event  The originating drag event.
   * @param {object} data      The Activity drag data.
   * @returns {Promise<Actor5e|void>}
   * @protected
   */
  async _onDropActivity(event, data) {
    if ( !event.target.closest(".favorites") ) return;
    const activity = await fromUuid(data.uuid);
    if ( !activity || (activity.actor !== this.actor) ) return;
    const uuid = `${activity.item.getRelativeUUID(this.actor)}.Activity.${activity.id}`;
    return this._onDropFavorite(event, { type: "activity", id: uuid });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropActor(event, data) {
    if ( !event.target.closest(".facility-occupants") || !data.uuid ) return super._onDropActor(event, data);
    const { facilityId } = event.target.closest("[data-facility-id]").dataset;
    const facility = this.actor.items.get(facilityId);
    if ( !facility ) return;
    const { prop } = event.target.closest("[data-prop]").dataset;
    const { max, value } = foundry.utils.getProperty(facility, prop);
    if ( (value.length + 1) > max ) return;
    return facility.update({ [`${prop}.value`]: [...value, data.uuid] });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropItem(event, data) {
    if ( !event.target.closest(".favorites") ) return super._onDropItem(event, data);
    const item = await Item.implementation.fromDropData(data);
    if ( item?.parent !== this.actor ) return super._onDropItem(event, data);
    const uuid = item.getRelativeUUID(this.actor);
    return this._onDropFavorite(event, { type: "item", id: uuid });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDropActiveEffect(event, data) {
    if ( !event.target.closest(".favorites") ) return super._onDropActiveEffect(event, data);
    const effect = await ActiveEffect.implementation.fromDropData(data);
    if ( effect.target !== this.actor ) return super._onDropActiveEffect(event, data);
    const uuid = effect.getRelativeUUID(this.actor);
    return this._onDropFavorite(event, { type: "effect", id: uuid });
  }

  /* -------------------------------------------- */

  /**
   * Handle an owned item or effect being dropped in the favorites area.
   * @param {DragEvent} event            The triggering event.
   * @param {ActorFavorites5e} favorite  The favorite that was dropped.
   * @returns {Promise<Actor5e>|void}
   * @protected
   */
  _onDropFavorite(event, favorite) {
    if ( this.actor.system.hasFavorite(favorite.id) ) return this._onSortFavorites(event, favorite.id);
    return this.actor.system.addFavorite(favorite);
  }

  /* -------------------------------------------- */

  /**
   * Handle removing a favorite.
   * @param {PointerEvent} event  The triggering event.
   * @returns {Promise<Actor5e>|void}
   * @protected
   */
  _onRemoveFavorite(event) {
    const { favoriteId } = event.currentTarget.closest("[data-favorite-id]")?.dataset ?? {};
    if ( !favoriteId ) return;
    return this.actor.system.removeFavorite(favoriteId);
  }

  /* -------------------------------------------- */

  /**
   * Handle re-ordering the favorites list.
   * @param {DragEvent} event  The drop event.
   * @param {string} srcId     The identifier of the dropped favorite.
   * @returns {Promise<Actor5e>|void}
   * @protected
   */
  _onSortFavorites(event, srcId) {
    const dropTarget = event.target.closest("[data-favorite-id]");
    if ( !dropTarget ) return;
    let source;
    let target;
    const targetId = dropTarget.dataset.favoriteId;
    if ( srcId === targetId ) return;
    const siblings = this.actor.system.favorites.filter(f => {
      if ( f.id === targetId ) target = f;
      else if ( f.id === srcId ) source = f;
      return f.id !== srcId;
    });
    const updates = SortingHelpers.performIntegerSort(source, { target, siblings });
    const favorites = this.actor.system.favorites.reduce((map, f) => map.set(f.id, { ...f }), new Map());
    for ( const { target, update } of updates ) {
      const favorite = favorites.get(target.id);
      foundry.utils.mergeObject(favorite, update);
    }
    return this.actor.update({ "system.favorites": Array.from(favorites.values()) });
  }

  /* -------------------------------------------- */

  /**
   * Handle using a facility.
   * @param {PointerEvent} event  The triggering event.
   * @returns {Promise|void}
   * @protected
   */
  _onUseFacility(event) {
    const { facilityId } = event.target.closest("[data-facility-id]")?.dataset ?? {};
    const facility = this.actor.items.get(facilityId);
    return facility?.use({ legacy: false, chooseActivity: true, event });
  }

  /* -------------------------------------------- */

  /**
   * Handle using a favorited item.
   * @param {PointerEvent} event  The triggering event.
   * @returns {Promise|void}
   * @protected
   */
  async _onUseFavorite(event) {
    if ( !this.isEditable || (event.target.tagName === "INPUT") ) return;
    const { favoriteId } = event.currentTarget.closest("[data-favorite-id]").dataset;
    const favorite = await fromUuid(favoriteId, { relative: this.actor });
    if ( (favorite instanceof dnd5e.documents.Item5e) || event.currentTarget.dataset.activityId ) {
      if ( favorite.type === "container" ) return favorite.sheet.render(true);
      return favorite.use({ legacy: false, event });
    }
    if ( favorite instanceof dnd5e.documents.ActiveEffect5e ) return favorite.update({ disabled: !favorite.disabled });
  }

  /* -------------------------------------------- */

  /**
   * Prepare bastion facility data for display.
   * @param {object} context  Render context.
   * @returns {Promise<void>}
   * @protected
   */
  async _prepareFacilities(context) {
    const allDefenders = [];
    const basic = [];
    const special = [];

    // TODO: Consider batching compendium lookups. Most occupants are likely to all be from the same compendium.
    for ( const facility of Object.values(this.actor.itemTypes.facility) ) {
      const { id, img, labels, name, system } = facility;
      const { building, craft, defenders, disabled, free, hirelings, progress, size, trade, type } = system;
      const subtitle = [
        building.built ? CONFIG.DND5E.facilities.sizes[size].label : game.i18n.localize("DND5E.FACILITY.Build.Unbuilt")
      ];
      if ( trade.stock.max ) subtitle.push(`${trade.stock.value ?? 0} &sol; ${trade.stock.max}`);
      const context = {
        id, labels, name, building, disabled, free, progress,
        craft: craft.item ? await fromUuid(craft.item) : null,
        creatures: await this._prepareFacilityOccupants(trade.creatures),
        defenders: await this._prepareFacilityOccupants(defenders),
        executing: CONFIG.DND5E.facilities.orders[progress.order]?.icon,
        hirelings: await this._prepareFacilityOccupants(hirelings),
        img: foundry.utils.getRoute(img),
        isSpecial: type.value === "special",
        subtitle: subtitle.join(" &bull; ")
      };
      allDefenders.push(...context.defenders.map(({ actor }) => {
        if ( !actor ) return null;
        const { img, name, uuid } = actor;
        return { img, name, uuid, facility: facility.id };
      }).filter(_ => _));
      if ( context.isSpecial ) special.push(context);
      else basic.push(context);
    }

    context.defenders = allDefenders;
    context.facilities = { basic: { chosen: basic }, special: { chosen: special } };
    ["basic", "special"].forEach(type => {
      const facilities = context.facilities[type];
      const config = CONFIG.DND5E.facilities.advancement[type];
      let [, available] = Object.entries(config).reverse().find(([level]) => {
        return level <= this.actor.system.details.level;
      }) ?? [];
      facilities.value = facilities.chosen.filter(({ free }) => (type === "basic") || !free).length;
      facilities.max = available ?? 0;
      available = (available ?? 0) - facilities.value;
      facilities.available = Array.fromRange(Math.max(0, available)).map(() => {
        return { label: `DND5E.FACILITY.AvailableFacility.${type}.free` };
      });
    });

    if ( !context.facilities.basic.available.length ) {
      context.facilities.basic.available.push({ label: "DND5E.FACILITY.AvailableFacility.basic.build" });
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare facility occupants for display.
   * @param {FacilityOccupants} occupants  The occupants.
   * @returns {Promise<object[]>}
   * @protected
   */
  _prepareFacilityOccupants(occupants) {
    const { max, value } = occupants;
    return Promise.all(Array.fromRange(max).map(async i => {
      const uuid = value[i];
      if ( uuid ) return { actor: await fromUuid(uuid) };
      return { empty: true };
    }));
  }

  /* -------------------------------------------- */

  /**
   * Prepare favorites for display.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareFavorites() {
    // Legacy resources
    const resources = Object.entries(this.actor.system.resources).reduce((arr, [k, r]) => {
      const { value, max, sr, lr, label } = r;
      const source = this.actor._source.system.resources[k];
      if ( label && max ) arr.push({
        id: `resources.${k}`,
        type: "resource",
        img: "icons/svg/upgrade.svg",
        resource: { value, max, source },
        css: "uses",
        title: label,
        subtitle: [
          sr ? game.i18n.localize("DND5E.AbbreviationSR") : null,
          lr ? game.i18n.localize("DND5E.AbbreviationLR") : null
        ].filterJoin(" &bull; ")
      });
      return arr;
    }, []);

    return resources.concat(await this.actor.system.favorites.reduce(async (arr, f) => {
      const { id, type, sort } = f;
      const favorite = await fromUuid(id, { relative: this.actor });
      if ( !favorite && ((type === "item") || (type === "effect") || (type === "activity")) ) return arr;
      arr = await arr;

      let data;
      if ( type === "item" ) data = await favorite.system.getFavoriteData();
      else if ( (type === "effect") || (type === "activity") ) data = await favorite.getFavoriteData();
      else data = await this._getFavoriteData(type, id);
      if ( !data ) return arr;
      let {
        img, title, subtitle, value, uses, quantity, modifier, passive,
        save, range, reference, toggle, suppressed, level
      } = data;

      if ( foundry.utils.getType(save?.ability) === "Set" ) save = {
        ...save, ability: save.ability.size > 2
          ? game.i18n.localize("DND5E.AbbreviationDC")
          : Array.from(save.ability).map(k => CONFIG.DND5E.abilities[k]?.abbreviation).filterJoin(" / ")
      };

      const css = [];
      if ( uses?.max ) {
        css.push("uses");
        uses.value = Math.round(uses.value);
      }
      else if ( modifier !== undefined ) css.push("modifier");
      else if ( save?.dc ) css.push("save");
      else if ( value !== undefined ) css.push("value");

      if ( toggle === false ) css.push("disabled");
      if ( uses?.max > 99 ) css.push("uses-sm");
      if ( modifier !== undefined ) {
        const value = Number(modifier.replace?.(/\s+/g, "") ?? modifier);
        if ( !isNaN(value) ) modifier = value;
      }

      const rollableClass = [];
      if ( this.isEditable && (type !== "slots") ) rollableClass.push("rollable");
      if ( type === "skill" ) rollableClass.push("skill-name");
      else if ( type === "tool" ) rollableClass.push("tool-name");

      if ( suppressed ) subtitle = game.i18n.localize("DND5E.Suppressed");
      const itemId = type === "item" ? favorite.id : type === "activity" ? favorite.item.id : null;
      arr.push({
        id, img, type, title, value, uses, sort, save, modifier, passive, range, reference, suppressed, level, itemId,
        effectId: type === "effect" ? favorite.id : null,
        parentId: (type === "effect") && (favorite.parent !== favorite.target) ? favorite.parent.id: null,
        activityId: type === "activity" ? favorite.id : null,
        preparationMode: (type === "slots") ? (/spell\d+/.test(id) ? "prepared" : id) : null,
        key: (type === "skill") || (type === "tool") ? id : null,
        toggle: toggle === undefined ? null : { applicable: true, value: toggle },
        quantity: quantity > 1 ? quantity : "",
        rollableClass: rollableClass.filterJoin(" "),
        css: css.filterJoin(" "),
        bareName: type === "slots",
        subtitle: Array.isArray(subtitle) ? subtitle.filterJoin(" &bull; ") : subtitle
      });
      return arr;
    }, []));
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for a favorited entry.
   * @param {"skill"|"tool"|"slots"} type  The type of favorite.
   * @param {string} id                    The favorite's identifier.
   * @returns {Promise<FavoriteData5e|void>}
   * @protected
   */
  async _getFavoriteData(type, id) {
    // Spell slots
    if ( type === "slots" ) {
      const { value, max, level } = this.actor.system.spells[id] ?? {};
      const uses = { value, max, name: `system.spells.${id}.value` };
      if ( !/spell\d+/.test(id) ) return {
        uses, level,
        title: game.i18n.localize(`DND5E.SpellSlots${id.capitalize()}`),
        subtitle: [
          game.i18n.localize(`DND5E.SpellLevel${level}`),
          game.i18n.localize(`DND5E.Abbreviation${CONFIG.DND5E.spellcastingTypes[id]?.shortRest ? "SR" : "LR"}`)
        ],
        img: CONFIG.DND5E.spellcastingTypes[id]?.img || CONFIG.DND5E.spellcastingTypes.pact.img
      };

      const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
      const isSR = CONFIG.DND5E.spellcastingTypes.leveled.shortRest;
      return {
        uses, level,
        title: game.i18n.format(`DND5E.SpellSlotsN.${plurals.select(level)}`, { n: level }),
        subtitle: game.i18n.localize(`DND5E.Abbreviation${isSR ? "SR" : "LR"}`),
        img: CONFIG.DND5E.spellcastingTypes.leveled.img.replace("{id}", id)
      };
    }

    // Skills & Tools
    else {
      const data = this.actor.system[`${type}s`]?.[id];
      if ( !data ) return;
      const { total, ability, passive } = data ?? {};
      const subtitle = game.i18n.format("DND5E.AbilityPromptTitle", {
        ability: CONFIG.DND5E.abilities[ability].label
      });
      let img;
      let title;
      let reference;
      if ( type === "tool" ) {
        reference = getBaseItemUUID(CONFIG.DND5E.toolIds[id]);
        ({ img, name: title } = getBaseItem(reference, { indexOnly: true }));
      }
      else if ( type === "skill" ) ({ icon: img, label: title, reference } = CONFIG.DND5E.skills[id]);
      return { img, title, subtitle, modifier: total, passive, reference };
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  canExpand(item) {
    return !["background", "race", "facility"].includes(item.type) && super.canExpand(item);
  }
}

/**
 * An Actor sheet for NPC type characters.
 */
class ActorSheet5eNPC extends ActorSheet5e {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e", "sheet", "actor", "npc"],
      width: 600
    });
  }

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = await super.getData(options);

    // Challenge Rating
    const cr = parseFloat(context.system.details.cr ?? 0);
    const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"};

    // Class Spellcasting
    context.classSpellcasting = Object.values(this.actor.classes).some(c => c.spellcasting?.levels);

    return foundry.utils.mergeObject(context, {
      labels: {
        cr: cr >= 1 ? String(cr) : crLabels[cr] ?? 1,
        type: context.system.details.type.label,
        armorType: this.getArmorLabel()
      }
    });
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareItems(context) {

    // Categorize Items as Features and Spells
    const features = {
      weapons: { label: game.i18n.localize("DND5E.AttackPl"), items: [], hasActions: true,
        dataset: {type: "weapon", "weapon-type": "natural"} },
      actions: { label: game.i18n.localize("DND5E.ActionPl"), items: [], hasActions: true,
        dataset: {type: "feat", "activation.type": "action"} },
      passive: { label: game.i18n.localize("DND5E.Features"), items: [], dataset: {type: "feat"} },
      equipment: { label: game.i18n.localize("DND5E.Inventory"), items: [], dataset: {type: "loot"}}
    };

    // Start by classifying items into groups for rendering
    const maxLevelDelta = CONFIG.DND5E.maxLevel - (this.actor.system.details.level ?? 0);
    const [spells, other] = context.items.reduce((arr, item) => {
      const { quantity } = item.system;
      const ctx = context.itemContext[item.id] ??= {};
      ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
      ctx.isExpanded = this._expanded.has(item.id);
      ctx.hasRecharge = item.hasRecharge;
      ctx.hasUses = item.hasLimitedUses;
      ctx.hasTarget = !!item.labels.target;
      ctx.canToggle = false;
      ctx.totalWeight = item.system.totalWeight?.toNearest(0.1);
      // Item grouping
      const isPassive = item.system.properties?.has("trait")
        || CONFIG.DND5E.activityActivationTypes[item.system.activities?.contents[0]?.activation.type]?.passive;
      ctx.group = isPassive ? "passive" : item.system.activities?.contents[0]?.activation.type || "passive";
      ctx.ungroup = "feat";
      if ( item.type === "weapon" ) ctx.ungroup = "weapon";
      if ( ctx.group === "passive" ) ctx.ungroup = "passive";
      // Individual item preparation
      this._prepareItem(item, ctx);
      if ( item.type === "class" ) ctx.availableLevels = Array.fromRange(CONFIG.DND5E.maxLevel, 1).map(level => {
        const delta = level - item.system.levels;
        let label = `${level}`;
        if ( delta ) label = `${label} (${formatNumber(delta, { signDisplay: "always" })})`;
        return { value: delta, label, disabled: delta > maxLevelDelta };
      });
      if ( item.type === "spell" ) arr[0].push(item);
      else arr[1].push(item);
      return arr;
    }, [[], []]);

    // Organize Spellbook
    const spellbook = this._prepareSpellbook(context, spells);

    // Organize Features
    for ( let item of other ) {
      if ( item.type === "weapon" ) features.weapons.items.push(item);
      else if ( ["background", "class", "feat", "race", "subclass"].includes(item.type) ) {
        if ( item.system.activities?.size ) features.actions.items.push(item);
        else features.passive.items.push(item);
      }
      else features.equipment.items.push(item);
    }

    // Assign and return
    context.inventoryFilters = true;
    context.features = Object.values(features);
    context.spellbook = spellbook;
  }

  /* -------------------------------------------- */

  /**
   * Format NPC armor information into a localized string.
   * @returns {string}  Formatted armor label.
   */
  getArmorLabel() {
    const ac = this.actor.system.attributes.ac;
    const label = [];
    if ( ac.calc === "default" ) label.push(this.actor.armor?.name || game.i18n.localize("DND5E.ArmorClassUnarmored"));
    else label.push(game.i18n.localize(CONFIG.DND5E.armorClasses[ac.calc].label));
    if ( this.actor.shield ) label.push(this.actor.shield.name);
    return label.filterJoin(", ");
  }

  /* -------------------------------------------- */

  /**
   * A helper method to establish the displayed preparation state for an item.
   * @param {Item5e} item     Item being prepared for display.
   * @param {object} context  Context data for display.
   * @protected
   */
  _prepareItem(item, context) {}

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    if ( !this.isEditable ) return;
    html.find(".rollable[data-action]").click(this._onSheetAction.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onConfigMenu(event) {
    event.preventDefault();
    event.stopPropagation();
    if ( (event.currentTarget.dataset.action === "type") && (this.actor.system.details.race?.id) ) {
      new CreatureTypeConfig({ document: this.actor.system.details.race, keyPath: "type" }).render(true);
    }
    else return super._onConfigMenu(event);
  }

  /* -------------------------------------------- */

  /**
   * Handle mouse click events for NPC sheet actions.
   * @param {MouseEvent} event  The originating click event.
   * @returns {Promise|void}
   * @private
   */
  _onSheetAction(event) {
    event.preventDefault();
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "editDescription":
        const { target } = button.closest("[data-target]").dataset;
        const editor = this.editors[target];
        editor.initial = foundry.utils.getProperty(this.actor, target);
        return this.activateEditor(name, {}, editor.initial);

      case "rollDeathSave":
        return this.actor.rollDeathSave({ event, legacy: false });

      case "rollInitiative":
        event.stopPropagation();
        return this.actor.rollInitiativeDialog({ event });
    }
  }

  /* -------------------------------------------- */
  /*  Object Updates                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {

    // Format NPC Challenge Rating
    const crs = { "1/8": 0.125, "⅛": 0.125, "1/4": 0.25, "¼": 0.25, "1/2": 0.5, "½": 0.5 };
    let crv = "system.details.cr";
    let cr = formData[crv];
    if ( (cr === "") || (cr === "—") ) formData[crv] = null;
    else {
      cr = crs[cr] || parseFloat(cr);
      if ( Number.isNaN(cr) ) cr = null;
      else formData[crv] = cr < 1 ? cr : parseInt(cr);
    }

    // Parent ActorSheet update steps
    return super._updateObject(event, formData);
  }
}

/**
 * An Actor sheet for NPCs.
 * @mixes ActorSheetV2
 */
class ActorSheet5eNPC2 extends ActorSheetV2Mixin(ActorSheet5eNPC) {
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e2", "sheet", "actor", "npc", "vertical-tabs"],
      width: 700,
      height: 700,
      resizable: true,
      scrollY: [".sheet-body"],
      tabs: [{ navSelector: ".tabs", contentSelector: ".tab-body", initial: "features" }],
      dragDrop: [
        { dragSelector: ".item-list .item > .item-row", dropSelector: null },
        { dragSelector: ".item-list .item .activity-row", dropSelector: null }
      ]
    });
  }

  /** @override */
  static TABS = [
    { tab: "features", label: "DND5E.Features", icon: "fas fa-list" },
    { tab: "inventory", label: "DND5E.Inventory", svg: "backpack" },
    { tab: "spells", label: "TYPES.Item.spellPl", icon: "fas fa-book" },
    { tab: "effects", label: "DND5E.Effects", icon: "fas fa-bolt" },
    { tab: "biography", label: "DND5E.Biography", icon: "fas fa-feather" },
    { tab: "special-traits", label: "DND5E.SpecialTraits", icon: "fas fa-star" }
  ];

  /**
   * The description currently being edited.
   * @type {string|null}
   */
  editingDescriptionTarget = null;

  /* -------------------------------------------- */

  /** @override */
  get template() {
    if ( !game.user.isGM && this.actor.limited ) return "systems/dnd5e/templates/actors/limited-sheet-2.hbs";
    return "systems/dnd5e/templates/actors/npc-sheet-2.hbs";
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderOuter() {
    const html = await super._renderOuter();
    this._renderSourceOuter(html);
    // XP value.
    html[0].querySelector(".header-elements")?.insertAdjacentHTML("beforeend", '<div class="cr-xp"></div>');
    return html;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _render(force=false, options={}) {
    await super._render(force, options);
    this._renderSource();
    const [elements] = this.element.find(".header-elements");
    if ( !elements || this.actor.limited ) return;
    const xp = this.actor.system.details.xp.value;
    elements.querySelector(".cr-xp").innerHTML = xp === null ? "" : game.i18n.format("DND5E.ExperiencePoints.Format", {
      value: new Intl.NumberFormat(game.i18n.lang).format(xp)
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = await super.getData(options);
    const { attributes, details, resources, traits } = this.actor.system;
    context.encumbrance = attributes.encumbrance;

    // Ability Scores
    Object.entries(context.abilities).forEach(([k, ability]) => {
      ability.key = k;
      ability.abbr = CONFIG.DND5E.abilities[k]?.abbreviation ?? "";
      ability.baseValue = context.source.abilities[k]?.value ?? 0;
      ability.icon = CONFIG.DND5E.abilities[k]?.icon;
    });

    // Important NPCs
    context.important = !foundry.utils.isEmpty(this.actor.classes) || traits.important;

    if ( this._mode === this.constructor.MODES.PLAY ) {
      context.showDeathSaves = context.important && !attributes.hp.value;
      context.showInitiativeScore = game.settings.get("dnd5e", "rulesVersion") === "modern";
    }

    // Loyalty
    context.showLoyalty = context.important && game.settings.get("dnd5e", "loyaltyScore") && game.user.isGM;

    // Habitat
    if ( details?.habitat?.value.length || details?.habitat?.custom ) {
      const { habitat } = details;
      const any = details.habitat.value.find(({ type }) => type === "any");
      context.habitat = habitat.value.reduce((arr, { type, subtype }) => {
        let { label } = CONFIG.DND5E.habitats[type] ?? {};
        if ( label && (!any || (type === "any")) ) {
          if ( subtype ) label = game.i18n.format("DND5E.Habitat.Subtype", { type: label, subtype });
          arr.push({ label });
        }
        return arr;
      }, [])
        .concat(splitSemicolons(habitat.custom).map(label => ({ label })))
        .sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    }

    // Treasure
    if ( details?.treasure?.value.size ) {
      const any = details.treasure.value.has("any");
      context.treasure = details.treasure.value.reduce((arr, id) => {
        const { label } = CONFIG.DND5E.treasure[id] ?? {};
        if ( label && (!any || (id === "any")) ) arr.push({ label });
        return arr;
      }, []).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    }

    // Speed
    context.speed = Object.entries(CONFIG.DND5E.movementTypes).reduce((obj, [k, label]) => {
      const value = attributes.movement[k];
      if ( value ) {
        obj[k] = { label, value };
        if ( (k === "fly") && attributes.movement.hover ) obj.fly.icons = [{
          icon: "fas fa-cloud", label: game.i18n.localize("DND5E.MovementHover")
        }];
      }
      return obj;
    }, {});

    // Skills & Tools
    const skillSetting = new Set(game.settings.get("dnd5e", "defaultSkills"));
    context.skills = Object.fromEntries(Object.entries(context.skills).filter(([k, v]) => {
      return v.value || skillSetting.has(k) || v.bonuses.check || v.bonuses.passive;
    }));

    // Senses
    if ( this.actor.system.skills.prc ) {
      context.senses.passivePerception = {
        label: game.i18n.localize("DND5E.PassivePerception"), value: this.actor.system.skills.prc.passive
      };
    }

    // Legendary Actions & Resistances
    const plurals = new Intl.PluralRules(game.i18n.lang, { type: "ordinal" });
    ["legact", "legres"].forEach(res => {
      const { max, value } = resources[res];
      context[res] = Array.fromRange(max, 1).map(n => {
        const i18n = res === "legact" ? "LegendaryAction" : "LegendaryResistance";
        const filled = value >= n;
        const classes = ["pip"];
        if ( filled ) classes.push("filled");
        return {
          n, filled,
          tooltip: `DND5E.${i18n}.Label`,
          label: game.i18n.format(`DND5E.${i18n}.Ordinal.${plurals.select(n)}`, { n }),
          classes: classes.join(" ")
        };
      });
    });
    context.hasLegendaries = resources.legact.max || resources.legres.max
      || (context.modernRules && resources.lair.value) || (!context.modernRules && resources.lair.initiative);

    // Spellcasting
    this._prepareSpellcasting(context);

    // Biographies
    const enrichmentOptions = {
      secrets: this.actor.isOwner, relativeTo: this.actor, rollData: context.rollData
    };

    context.enriched = {
      public: await TextEditor.enrichHTML(this.actor.system.details.biography.public, enrichmentOptions),
      value: context.biographyHTML
    };

    if ( this.editingDescriptionTarget ) {
      context.editingDescriptionTarget = this.editingDescriptionTarget;
      context.enriched.editing = this.editingDescriptionTarget.endsWith("public")
        ? context.enriched.public
        : context.enriched.value;
    }

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareFlags() {
    const flags = super._prepareFlags();
    const source = (this._mode === this.constructor.MODES.PLAY ? this.document : this.document._source);

    flags.sections.unshift({
      label: game.i18n.localize("DND5E.NPC.Label"),
      fields: [{
        field: this.document.system.schema.fields.traits.fields.important,
        input: createCheckboxInput,
        name: "system.traits.important",
        value: source.system.traits.important
      }]
    });

    return flags;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _prepareItems(context) {
    super._prepareItems(context);
    const classes = [];
    const inventory = {};
    const inventoryTypes = Object.entries(CONFIG.Item.dataModels)
      .filter(([, model]) => model.metadata?.inventoryItem)
      .sort(([, lhs], [, rhs]) => (lhs.metadata.inventoryOrder - rhs.metadata.inventoryOrder));
    for ( const [type] of inventoryTypes ) {
      inventory[type] = { label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: { type } };
      if ( type === "container" ) context.containers = inventory.container.items;
    }
    const features = context.features.filter(section => {
      if ( section.dataset.type === "loot" ) {
        section.items.forEach(i => inventory[i.type]?.items.push(i));
        return false;
      }
      if ( (section.dataset.type === "feat") ) {
        if ( !("activation.type" in section.dataset) ) section.dataset.type = "passive";
        for ( let i = section.items.length; i--; ) {
          const item = section.items[i];
          if ( (item.type === "class") || (item.type === "subclass") ) {
            classes.push(item);
            section.items.splice(i, 1);
            context.itemContext[item.id].prefixedImage = item.img ? foundry.utils.getRoute(item.img) : null;
          }
        }
      }
      if ( section.dataset.type === "weapon" ) inventory.weapon.items = section.items;
      return true;
    });
    // TODO: These labels should be pluralised.
    Object.entries(CONFIG.DND5E.abilityActivationTypes).forEach(([type, label]) => features.push({
      label, items: [], hasActions: true, dataset: { type }
    }));
    context.features = {
      sections: features,
      filters: [
        { key: "action", label: "DND5E.Action" },
        { key: "bonus", label: "DND5E.BonusAction" },
        { key: "reaction", label: "DND5E.Reaction" },
        { key: "legendary", label: "DND5E.LegendaryAction.Label" },
        { key: "lair", label: "DND5E.LAIR.Action.Label" }
      ]
    };
    features.forEach(section => {
      section.categories = [
        {
          classes: "item-uses", label: "DND5E.Uses", itemPartial: "dnd5e.column-uses",
          activityPartial: "dnd5e.activity-column-uses"
        },
        {
          classes: "item-roll", label: "DND5E.SpellHeader.Roll", itemPartial: "dnd5e.column-roll",
          activityPartial: "dnd5e.activity-column-roll"
        },
        {
          classes: "item-formula", label: "DND5E.SpellHeader.Formula", itemPartial: "dnd5e.column-formula",
          activityPartial: "dnd5e.activity-column-formula"
        },
        {
          classes: "item-controls", itemPartial: "dnd5e.column-feature-controls",
          activityPartial: "dnd5e.activity-column-controls"
        }
      ];
    });
    context.inventory = Object.values(inventory);
    context.inventory.push({ label: "DND5E.Contents", items: [], dataset: { type: "all" } });
    context.inventory.forEach(section => {
      section.categories = [
        { activityPartial: "dnd5e.activity-column-price" },
        { activityPartial: "dnd5e.activity-column-weight" },
        { activityPartial: "dnd5e.activity-column-quantity" },
        { activityPartial: "dnd5e.activity-column-uses" }
      ];
    });
    context.classes = classes;
    context.hasClasses = classes.length;
  }

  /* -------------------------------------------- */

  /**
   * Prepare spellcasting data for display.
   * @param {object} context  The display context.
   * @protected
   */
  _prepareSpellcasting(context) {
    const { abilities, attributes, bonuses } = this.actor.system;
    context.spellcasting = [];
    const msak = simplifyBonus(bonuses.msak.attack, context.rollData);
    const rsak = simplifyBonus(bonuses.rsak.attack, context.rollData);
    // TODO: Consider if we want to handle multiclassing for NPC spellcasters.
    const spellcaster = Object.values(this.actor.classes).find(cls => cls.system.spellcasting.progression !== "none");
    const ability = spellcaster?.spellcasting.ability ?? attributes.spellcasting;
    const spellAbility = abilities[ability];
    const mod = spellAbility?.mod ?? 0;
    const attackBonus = msak === rsak ? msak : 0;
    context.spellcasting.push({
      label: game.i18n.format("DND5E.SpellcastingClass", {
        class: spellcaster?.name ?? game.i18n.format("DND5E.NPC.Label")
      }),
      level: spellcaster?.system.levels ?? attributes.spell.level,
      ability: {
        ability, mod,
        label: CONFIG.DND5E.abilities[ability]?.abbreviation
      },
      attack: mod + attributes.prof + attackBonus,
      save: spellAbility?.dc ?? 0,
      noSpellcaster: !spellcaster,
      concentration: {
        mod: attributes.concentration.save,
        tooltip: game.i18n.format("DND5E.AbilityConfigure", { ability: game.i18n.localize("DND5E.Concentration") })
      }
    });
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".short-rest").on("click", this._onShortRest.bind(this));
    html.find(".long-rest").on("click", this._onLongRest.bind(this));

    if ( this.isEditable ) {
      html.find(".editor-edit").on("click", this._onEditBiography.bind(this));
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onShowPortrait(event) {
    if ( !event.target.closest(".death-saves") ) return super._onShowPortrait();
  }

  /* -------------------------------------------- */

  /**
   * Take a short rest, calling the relevant function on the Actor instance.
   * @param {Event} event             The triggering click event.
   * @returns {Promise<RestResult>}  Result of the rest action.
   * @protected
   */
  async _onShortRest(event) {
    event.preventDefault();
    await this._onSubmit(event);
    return this.actor.shortRest();
  }

  /* -------------------------------------------- */

  /**
   * Take a long rest, calling the relevant function on the Actor instance.
   * @param {Event} event             The triggering click event.
   * @returns {Promise<RestResult>}  Result of the rest action.
   * @protected
   */
  async _onLongRest(event) {
    event.preventDefault();
    await this._onSubmit(event);
    return this.actor.longRest();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async activateEditor(name, options={}, initialContent="") {
    options.relativeLinks = true;
    options.plugins = {
      menu: ProseMirror.ProseMirrorMenu.build(ProseMirror.defaultSchema, {
        compact: true,
        destroyOnSave: true,
        onSave: () => {
          this.saveEditor(name, { remove: true });
          this.editingDescriptionTarget = null;
        }
      })
    };
    return super.activateEditor(name, options, initialContent);
  }

  /* -------------------------------------------- */

  /**
   * Handle editing a biography section.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onEditBiography(event) {
    const { target } = event.currentTarget.closest("[data-target]").dataset;
    this.editingDescriptionTarget = target;
    this.render();
  }
}

/**
 * An Actor sheet for Vehicle type actors.
 */
class ActorSheet5eVehicle extends ActorSheet5e {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e", "sheet", "actor", "vehicle"]
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static unsupportedItemTypes = new Set(["background", "class", "race", "subclass"]);

  /* -------------------------------------------- */

  /**
   * Creates a new cargo entry for a vehicle Actor.
   * @type {object}
   */
  static get newCargo() {
    return {name: "", quantity: 1};
  }

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @override */
  _getMovementSpeed(actorData, largestPrimary=true) {
    return super._getMovementSpeed(actorData, largestPrimary);
  }

  /* -------------------------------------------- */

  /**
   * Prepare items that are mounted to a vehicle and require one or more crew to operate.
   * @param {object} item     Copy of the item data being prepared for display.
   * @param {object} context  Display context for the item.
   * @protected
   */
  _prepareCrewedItem(item, context) {

    // Determine crewed status
    const isCrewed = item.system.crewed;
    context.toggleClass = isCrewed ? "active" : "";
    context.toggleTitle = game.i18n.localize(`DND5E.${isCrewed ? "Crewed" : "Uncrewed"}`);

    // Handle crew actions
    if ( (item.type === "feat") && (item.system.activation.type === "crew") ) {
      if ( item.system.cover === 1 ) context.cover = game.i18n.localize("DND5E.CoverTotal");
      else if ( item.system.cover === .5 ) context.cover = "½";
      else if ( item.system.cover === .75 ) context.cover = "¾";
      else context.cover = "—";
    }

    // Prepare vehicle weapons
    if ( (item.type === "equipment") || (item.type === "weapon") ) {
      context.threshold = item.system.hp?.dt ? item.system.hp.dt : "—";
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _prepareItems(context) {
    const cargoColumns = [{
      label: game.i18n.localize("DND5E.Quantity"),
      css: "item-qty",
      property: "quantity",
      editable: "Number"
    }];

    const equipmentColumns = [{
      label: game.i18n.localize("DND5E.Quantity"),
      css: "item-qty",
      property: "system.quantity",
      editable: "Number"
    }, {
      label: game.i18n.localize("DND5E.AC"),
      css: "item-ac",
      property: "system.armor.value"
    }, {
      label: game.i18n.localize("DND5E.HP"),
      css: "item-hp",
      property: "system.hp.value",
      maxProperty: "system.hp.max",
      editable: "Number"
    }, {
      label: game.i18n.localize("DND5E.Threshold"),
      css: "item-threshold",
      property: "threshold"
    }];

    const features = {
      actions: {
        label: game.i18n.localize("DND5E.ActionPl"),
        items: [],
        hasActions: true,
        crewable: true,
        dataset: {type: "feat", "activation.type": "crew"},
        columns: [{
          label: game.i18n.localize("DND5E.Cover"),
          css: "item-cover",
          property: "cover"
        }]
      },
      equipment: {
        label: game.i18n.localize(CONFIG.Item.typeLabels.equipment),
        items: [],
        crewable: true,
        dataset: {type: "equipment", "type.value": "vehicle"},
        columns: equipmentColumns
      },
      passive: {
        label: game.i18n.localize("DND5E.Features"),
        items: [],
        dataset: {type: "feat"}
      },
      reactions: {
        label: game.i18n.localize("DND5E.ReactionPl"),
        items: [],
        dataset: {type: "feat", "activation.type": "reaction"}
      },
      weapons: {
        label: game.i18n.localize(`${CONFIG.Item.typeLabels.weapon}Pl`),
        items: [],
        crewable: true,
        dataset: {type: "weapon", "weapon-type": "siege"},
        columns: equipmentColumns
      }
    };

    context.items.forEach(item => {
      const {uses} = item.system;
      const ctx = context.itemContext[item.id] ??= {};
      ctx.canToggle = false;
      ctx.isExpanded = this._expanded.has(item.id);
      ctx.hasUses = uses && (uses.max > 0);
    });

    const cargo = {
      crew: {
        label: game.i18n.localize("DND5E.VehicleCrew"),
        items: context.actor.system.cargo.crew,
        css: "cargo-row crew",
        editableName: true,
        dataset: {type: "crew"},
        columns: cargoColumns
      },
      passengers: {
        label: game.i18n.localize("DND5E.VehiclePassengers"),
        items: context.actor.system.cargo.passengers,
        css: "cargo-row passengers",
        editableName: true,
        dataset: {type: "passengers"},
        columns: cargoColumns
      },
      cargo: {
        label: game.i18n.localize("DND5E.VehicleCargo"),
        items: [],
        dataset: {type: "loot"},
        columns: [{
          label: game.i18n.localize("DND5E.Quantity"),
          css: "item-qty",
          property: "system.quantity",
          editable: "Number"
        }, {
          label: game.i18n.localize("DND5E.Price"),
          css: "item-price",
          property: "system.price.value",
          editable: "Number"
        }, {
          label: game.i18n.localize("DND5E.Weight"),
          css: "item-weight",
          property: "system.weight.value",
          editable: "Number"
        }]
      }
    };

    // Classify items owned by the vehicle and compute total cargo weight
    for ( const item of context.items ) {
      const ctx = context.itemContext[item.id] ??= {};
      this._prepareCrewedItem(item, ctx);

      // Handle cargo explicitly
      const isCargo = item.flags.dnd5e?.vehicleCargo === true;
      if ( isCargo ) {
        cargo.cargo.items.push(item);
        continue;
      }

      // Handle non-cargo item types
      switch ( item.type ) {
        case "weapon":
          features.weapons.items.push(item);
          break;
        case "equipment":
          features.equipment.items.push(item);
          break;
        case "feat":
          const act = item.system.activation;
          if ( !act.type || (act.type === "none") ) features.passive.items.push(item);
          else if (act.type === "reaction") features.reactions.items.push(item);
          else features.actions.items.push(item);
          break;
        case "spell":
          break;
        default:
          cargo.cargo.items.push(item);
      }
    }

    // Update the rendering context data
    context.inventoryFilters = false;
    context.features = Object.values(features);
    context.cargo = Object.values(cargo);
    context.encumbrance = context.system.attributes.encumbrance;
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @override */
  activateListeners(html) {
    super.activateListeners(html);
    if ( !this.isEditable ) return;

    html[0].querySelector('[data-tab="cargo"] dnd5e-inventory')
      .addEventListener("inventory", this._onInventoryEvent.bind(this));

    html.find(".cargo-row input")
      .click(evt => evt.target.select())
      .change(this._onCargoRowChange.bind(this));

    if (this.actor.system.attributes.actions.stations) {
      html.find(".counter.actions, .counter.action-thresholds").hide();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle saving a cargo row (i.e. crew or passenger) in-sheet.
   * @param {Event} event              Triggering event.
   * @returns {Promise<Actor5e>|null}  Actor after update if any changes were made.
   * @private
   */
  _onCargoRowChange(event) {
    event.preventDefault();
    const target = event.currentTarget;
    const row = target.closest(".item");
    const idx = Number(row.dataset.itemIndex);
    const property = row.classList.contains("crew") ? "crew" : "passengers";

    // Get the cargo entry
    const cargo = foundry.utils.deepClone(this.actor.system.cargo[property]);
    const entry = cargo[idx];
    if ( !entry ) return null;

    // Update the cargo value
    const key = target.dataset.name ?? "name";
    const type = target.dataset.dtype;
    let value = target.value;
    if (type === "Number") value = Number(value);
    entry[key] = value;

    // Perform the Actor update
    return this.actor.update({[`system.cargo.${property}`]: cargo});
  }

  /* -------------------------------------------- */

  /**
   * Handle creating and deleting crew and passenger rows.
   * @param {CustomEvent} event   Triggering inventory event.
   * @returns {Promise}
   */
  async _onInventoryEvent(event) {
    if ( event.detail === "create" ) {
      const type = event.target.dataset.type;
      if ( !["crew", "passengers"].includes(type) ) return;
      event.preventDefault();
      const cargoCollection = foundry.utils.deepClone(this.actor.system.cargo[type]);
      cargoCollection.push(this.constructor.newCargo);
      return this.actor.update({[`system.cargo.${type}`]: cargoCollection});
    }

    else if ( event.detail === "delete" ) {
      const row = event.target.closest(".item");
      if ( !row.classList.contains("cargo-row") ) return;
      event.preventDefault();
      const idx = Number(row.dataset.itemIndex);
      const type = row.classList.contains("crew") ? "crew" : "passengers";
      const cargoCollection = foundry.utils.deepClone(this.actor.system.cargo[type]).filter((_, i) => i !== idx);
      return this.actor.update({[`system.cargo.${type}`]: cargoCollection});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropSingleItem(itemData, event) {
    const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "container"];
    const isCargo = cargoTypes.includes(itemData.type) && (this._tabs[0].active === "cargo");
    foundry.utils.setProperty(itemData, "flags.dnd5e.vehicleCargo", isCargo);
    return super._onDropSingleItem(itemData, event);
  }
}

/**
 * A character sheet for group-type Actors.
 * The functionality of this sheet is sufficiently different from other Actor types that we extend the base
 * Foundry VTT ActorSheet instead of the ActorSheet5e abstraction used for character, npc, and vehicle types.
 */
class GroupActorSheet extends ActorSheetMixin(ActorSheet) {

  /**
   * IDs for items on the sheet that have been expanded.
   * @type {Set<string>}
   * @protected
   */
  _expanded = new Set();

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e", "sheet", "actor", "group"],
      template: "systems/dnd5e/templates/actors/group-sheet.hbs",
      tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "members"}],
      scrollY: ["dnd5e-inventory .inventory-list"],
      width: 620,
      height: 620,
      elements: {
        inventory: "dnd5e-inventory"
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * A set of item types that should be prevented from being dropped on this type of actor sheet.
   * @type {Set<string>}
   */
  static unsupportedItemTypes = new Set(["background", "race", "class", "subclass", "feat"]);

  /* -------------------------------------------- */
  /*  Context Preparation                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options={}) {
    const context = super.getData(options);
    context.system = this.actor.system;
    context.items = Array.from(this.actor.items);
    context.config = CONFIG.DND5E;
    context.isGM = game.user.isGM;

    // Membership
    const {sections, stats} = this.#prepareMembers();
    Object.assign(context, stats);
    context.sections = sections;

    // Movement
    context.movement = this.#prepareMovementSpeed();

    // XP
    if ( game.settings.get("dnd5e", "levelingMode") !== "noxp" ) context.xp = context.system.details.xp;

    // Inventory
    context.itemContext = {};
    context.inventory = this.#prepareInventory(context);
    context.elements = this.options.elements;
    context.expandedData = {};
    for ( const id of this._expanded ) {
      const item = this.actor.items.get(id);
      if ( item ) context.expandedData[id] = await item.getChatData({secrets: this.actor.isOwner});
    }
    context.inventoryFilters = false;
    context.rollableClass = this.isEditable ? "rollable" : "";

    // Biography HTML
    context.descriptionFull = await TextEditor.enrichHTML(this.actor.system.description.full, {
      secrets: this.actor.isOwner,
      rollData: context.rollData,
      relativeTo: this.actor
    });

    // Summary tag
    context.summary = this.#getSummary(stats);

    // Text labels
    context.labels = {
      currencies: Object.entries(CONFIG.DND5E.currencies).reduce((obj, [k, c]) => {
        obj[k] = c.label;
        return obj;
      }, {})
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare a localized summary of group membership.
   * @param {{nMembers: number, nVehicles: number}} stats     The number of members in the group
   * @returns {string}                                        The formatted summary string
   */
  #getSummary(stats) {
    const formatter = game.i18n.getListFormatter({ style: "long", type: "conjunction" });
    const rule = new Intl.PluralRules(game.i18n.lang);
    const members = [];
    if ( stats.nMembers ) {
      members.push(`${stats.nMembers} ${game.i18n.localize(`DND5E.Group.Member.${rule.select(stats.nMembers)}`)}`);
    }
    if ( stats.nVehicles ) {
      members.push(`${stats.nVehicles} ${game.i18n.localize(`DND5E.Group.Vehicle.${rule.select(stats.nVehicles)}`)}`);
    }
    if ( !members.length ) return game.i18n.localize("DND5E.GroupSummaryEmpty");
    return game.i18n.format("DND5E.GroupSummary", {members: formatter.format(members)});
  }

  /* -------------------------------------------- */

  /**
   * Prepare membership data for the sheet.
   * @returns {{sections: object, stats: object}}
   */
  #prepareMembers() {
    const stats = {
      currentHP: 0,
      maxHP: 0,
      nMembers: 0,
      nVehicles: 0
    };
    const sections = {
      character: {label: `${CONFIG.Actor.typeLabels.character}Pl`, members: []},
      npc: {label: `${CONFIG.Actor.typeLabels.npc}Pl`, members: []},
      vehicle: {label: `${CONFIG.Actor.typeLabels.vehicle}Pl`, members: []}
    };
    const type = this.actor.system.type.value;
    const displayXP = game.settings.get("dnd5e", "levelingMode") !== "noxp";
    for ( const [index, memberData] of this.object.system.members.entries() ) {
      const member = memberData.actor;
      const multiplier = type === "encounter" ? (memberData.quantity.value ?? 1) : 1;

      const m = {
        index,
        ...memberData,
        actor: member,
        id: member.id,
        name: member.name,
        img: member.img,
        hp: {},
        displayHPValues: member.testUserPermission(game.user, "OBSERVER")
      };

      // HP bar
      const hp = member.system.attributes.hp;
      m.hp.current = hp.value + (hp.temp || 0);
      m.hp.max = Math.max(0, hp.effectiveMax);
      m.hp.pct = Math.clamp((m.hp.current / m.hp.max) * 100, 0, 100).toFixed(2);
      m.hp.color = dnd5e.documents.Actor5e.getHPColor(m.hp.current, m.hp.max).css;
      stats.currentHP += (m.hp.current * multiplier);
      stats.maxHP += (m.hp.max * multiplier);

      // Challenge
      if ( member.type === "npc" ) {
        m.cr = formatCR(member.system.details.cr);
        if ( displayXP ) m.xp = formatNumber(member.system.details.xp.value * multiplier);
      }

      if ( member.type === "vehicle" ) stats.nVehicles += multiplier;
      else stats.nMembers += multiplier;
      sections[member.type].members.push(m);
    }
    for ( const [k, section] of Object.entries(sections) ) {
      if ( !section.members.length ) delete sections[k];
      else {
        section.displayHPColumn = type !== "encounter";
        section.displayQuantityColumn = type === "encounter";
        section.displayChallengeColumn = (type === "encounter") && (k === "npc");
      }
    }
    return {sections, stats};
  }

  /* -------------------------------------------- */

  /**
   * Prepare movement speed data for rendering on the sheet.
   * @returns {{secondary: string, primary: string}}
   */
  #prepareMovementSpeed() {
    const movement = this.object.system.attributes.movement;
    let speeds = [
      [movement.land, `${game.i18n.localize("DND5E.MovementLand")} ${movement.land}`],
      [movement.water, `${game.i18n.localize("DND5E.MovementWater")} ${movement.water}`],
      [movement.air, `${game.i18n.localize("DND5E.MovementAir")} ${movement.air}`]
    ];
    speeds = speeds.filter(s => s[0]).sort((a, b) => b[0] - a[0]);
    const primary = speeds.shift();
    return {
      primary: `${primary ? primary[1] : "0"}`,
      secondary: speeds.map(s => s[1]).join(", ")
    };
  }

  /* -------------------------------------------- */

  /**
   * Prepare inventory items for rendering on the sheet.
   * @param {object} context  Prepared rendering context.
   * @returns {Object<string,object>}
   */
  #prepareInventory(context) {

    // Categorize as weapons, equipment, containers, and loot
    const sections = {};
    for ( const type of ["weapon", "equipment", "consumable", "container", "loot"] ) {
      sections[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], hasActions: false, dataset: {type}};
    }

    // Remove items in containers & sort remaining
    context.items = context.items
      .filter(i => !this.actor.items.has(i.system.container) && (i.type !== "spell"))
      .sort((a, b) => (a.sort || 0) - (b.sort || 0));

    // Classify items
    for ( const item of context.items ) {
      const ctx = context.itemContext[item.id] ??= {};
      const {quantity} = item.system;
      ctx.isStack = Number.isNumeric(quantity) && (quantity > 1);
      ctx.canToggle = false;
      ctx.isExpanded = this._expanded.has(item.id);
      ctx.hasUses = item.hasLimitedUses;
      if ( (item.type in sections) && (item.type !== "loot") ) sections[item.type].items.push(item);
      else sections.loot.items.push(item);
    }

    return sections;
  }

  /* -------------------------------------------- */
  /*  Rendering Workflow                          */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _render(force, options={}) {
    for ( const member of this.object.system.members) {
      member.actor.apps[this.id] = this;
    }
    return super._render(force, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options={}) {
    for ( const member of this.object.system.members ) {
      delete member.actor.apps[this.id];
    }
    return super.close(options);
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find(".group-member .name").click(this._onClickMemberName.bind(this));
    if ( this.isEditable ) {
      // Input focus and update
      const inputs = html.find("input");
      inputs.focus(ev => ev.currentTarget.select());
      inputs.addBack().find('[type="text"][data-dtype="Number"]').change(this._onChangeInputDelta.bind(this));
      html.find(".action-button").click(this._onClickActionButton.bind(this));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks to action buttons on the group sheet.
   * @param {PointerEvent} event      The initiating click event
   * @protected
   */
  _onClickActionButton(event) {
    event.preventDefault();
    const button = event.currentTarget;
    switch ( button.dataset.action ) {
      case "award":
        const award = new Award({
          award: { savedDestinations: this.actor.getFlag("dnd5e", "awardDestinations") },
          origin: this.object
        });
        award.render(true);
        break;
      case "longRest":
        this.actor.longRest({ advanceTime: true });
        break;
      case "movementConfig":
        new MovementSensesConfig({ document: this.actor, type: "movement" }).render({ force: true });
        break;
      case "placeMembers":
        this.actor.system.placeMembers();
        break;
      case "removeMember":
        const removeMemberId = button.closest("li.group-member").dataset.actorId;
        this.actor.system.removeMember(removeMemberId);
        break;
      case "rollQuantities":
        this.actor.system.rollQuantities();
        break;
      case "shortRest":
        this.actor.shortRest({ advanceTime: true });
        break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks on member names in the members list.
   * @param {PointerEvent} event      The initiating click event
   * @protected
   */
  _onClickMemberName(event) {
    event.preventDefault();
    const member = event.currentTarget.closest("li.group-member");
    const actor = game.actors.get(member.dataset.actorId);
    if ( actor ) actor.sheet.render(true, {focus: true});
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropActor(event, data) {
    if ( !this.isEditable ) return;
    const cls = getDocumentClass("Actor");
    const sourceActor = await cls.fromDropData(data);
    if ( !sourceActor ) return;
    return this.object.system.addMember(sourceActor);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropItem(event, data) {
    const behavior = this._dropBehavior(event, data);
    if ( !this.actor.isOwner || (behavior === "none") ) return false;
    const item = await Item.implementation.fromDropData(data);

    // Handle moving out of container & item sorting
    if ( (behavior === "move") && (this.actor.uuid === item.parent?.uuid) ) {
      if ( item.system.container !== null ) await item.update({"system.container": null});
      return this._onSortItem(event, item.toObject());
    }

    return this._onDropItemCreate(item, event, behavior);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropFolder(event, data) {
    if ( !this.actor.isOwner ) return [];
    const folder = await Folder.implementation.fromDropData(data);
    if ( folder.type !== "Item" ) return [];
    const droppedItemData = await Promise.all(folder.contents.map(async item => {
      if ( !(item instanceof Item) ) item = await fromUuid(item.uuid);
      return item;
    }));
    return this._onDropItemCreate(droppedItemData, event);
  }

  /* -------------------------------------------- */

  /** @override */
  async _onDropItemCreate(itemData, event, behavior) {
    let items = itemData instanceof Array ? itemData : [itemData];

    // Filter out items already in containers to avoid creating duplicates
    const containers = new Set(items.filter(i => i.type === "container").map(i => i._id));
    items = items.filter(i => !containers.has(i.system.container));

    // Create the owned items & contents as normal
    const toCreate = await Item5e.createWithContents(items, {
      transformFirst: item => this._onDropSingleItem(item.toObject(), event)
    });
    const created = await Item5e.createDocuments(toCreate, { pack: this.actor.pack, parent: this.actor, keepId: true });
    if ( behavior === "move" ) items.forEach(i => fromUuid(i.uuid).then(d => d?.delete({ deleteContents: true })));
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Handles dropping of a single item onto this group sheet.
   * @param {object} itemData            The item data to create.
   * @param {DragEvent} event            The concluding DragEvent which provided the drop data.
   * @returns {Promise<object|boolean>}  The item data to create after processing, or false if the item should not be
   *                                     created or creation has been otherwise handled.
   * @protected
   */
  async _onDropSingleItem(itemData, event) {

    // Check to make sure items of this type are allowed on this actor
    if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) {
      ui.notifications.warn(game.i18n.format("DND5E.ActorWarningInvalidItem", {
        itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]),
        actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type])
      }));
      return false;
    }

    // Create a Consumable spell scroll on the Inventory tab
    if ( itemData.type === "spell" ) {
      const scroll = await Item5e.createScrollFromSpell(itemData);
      return scroll?.toObject?.();
    }

    // Stack identical consumables
    const stacked = this._onDropStackConsumables(itemData);
    if ( stacked ) return false;

    return itemData;
  }
}

/**
 * A simple form to set save throw configuration for a given ability score.
 *
 * @param {Actor5e} actor               The Actor instance being displayed within the sheet.
 * @param {ApplicationOptions} options  Additional application configuration options.
 * @param {string} abilityId            The ability key as defined in CONFIG.DND5E.abilities.
 */
class ActorAbilityConfig extends AbilityConfig {
  constructor(actor, options, abilityId) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorAbilityConfig` application has been deprecated and replaced with `AbilityConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor, key: abilityId });
  }
}

/**
 * Interface for managing a character's armor calculation.
 */
class ActorArmorConfig extends ArmorClassConfig {
  constructor(actor, options) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorArmorConfig` application has been deprecated and replaced with `ArmorClassConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor });
  }
}

/**
 * A sub-application of the ActorSheet used to configure concentration saving throws.
 * @extends {BaseConfigSheet}
 */
class ActorConcentrationConfig extends ConcentrationConfig {
  constructor(actor, options) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorConcentrationConfig` application has been deprecated and replaced with `ConcentrationConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor });
  }
}

/**
 * Configuration app for damage modification.
 */
class DamageModificationConfig extends DamagesConfig {
  constructor(actor, trait, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `DamageModificationConfig` application has been deprecated and replaced with `DamagesConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor, trait: "dm" });
  }
}

/**
 * A simple form to set actor hit dice amounts.
 */
class ActorHitDiceConfig extends HitDiceConfig {
  constructor(actor, options) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorHitDiceConfig` application has been deprecated and replaced with `HitDiceConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor });
  }
}

/**
 * A form for configuring actor hit points and bonuses.
 */
class ActorHitPointsConfig extends HitPointsConfig {
  constructor(actor, options) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorHitPointsConfig` application has been deprecated and replaced with `HitPointsConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor });
  }
}

/**
 * A simple sub-application of the ActorSheet which is used to configure properties related to initiative.
 */
class ActorInitiativeConfig extends InitiativeConfig {
  constructor(actor, options, abilityId) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorInitiativeConfig` application has been deprecated and replaced with `InitiativeConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor });
  }
}

/**
 * A simple form to set actor movement speeds.
 */
class ActorMovementConfig extends MovementSensesConfig {
  constructor(actor, options) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorMovementConfig` application has been deprecated and replaced with `MovementSensesConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor, type: "movement" });
  }
}

/**
 * @typedef {FormApplicationOptions} ProficiencyConfigOptions
 * @property {string} key       The ID of the skill or tool being configured.
 * @property {string} property  The property on the actor being configured, either 'skills', or 'tools'.
 */

/**
 * An application responsible for configuring proficiencies and bonuses in tools and skills.
 *
 * @param {Actor5e} actor                     The Actor being configured.
 * @param {ProficiencyConfigOptions} options  Additional configuration options.
 */
class ProficiencyConfig extends SkillToolConfig {
  constructor(actor, options) {
    foundry.utils.logCompatibilityWarning(
      "The `ProficiencyConfig` application has been deprecated and replaced with `SkillToolConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    const trait = options.property === "skills" ? "skills" : "tool";
    super({ ...options, document: actor, trait });
  }
}

/**
 * A simple form to configure Actor senses.
 */
class ActorSensesConfig extends MovementSensesConfig {
  constructor(actor, options) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorSensesConfig` application has been deprecated and replaced with `MovementSensesConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor, type: "senses" });
  }
}

class ActorSkillsConfig extends SkillsConfig {
  constructor(document, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorSkillsConfig` application has been deprecated and replaced with `SkillsConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document });
  }
}

class ActorSpellSlotsConfig extends SpellSlotsConfig {
  constructor(actor, options, abilityId) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorSpellSlotsConfig` application has been deprecated and replaced with `SpellSlotsConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor });
  }
}

/**
 * A specialized version of the TraitSelector used for selecting tool and vehicle proficiencies.
 * @extends {TraitSelector}
 */
class ToolSelector extends ToolsConfig {
  constructor(actor, trait, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `ToolSelector` application has been deprecated and replaced with `ToolsConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor });
  }
}

/**
 * A specialized application used to modify actor traits.
 *
 * @param {Actor5e} actor                       Actor for whose traits are being edited.
 * @param {string} trait                        Trait key as defined in CONFIG.traits.
 * @param {object} [options={}]
 * @param {boolean} [options.allowCustom=true]  Support user custom trait entries.
 */
class TraitSelector extends TraitsConfig {
  constructor(actor, trait, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `TraitSelector` application has been deprecated and replaced with `BaseTraitsConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    super({ ...options, document: actor, trait });
  }
}

/**
 * A specialized form used to select from a checklist of attributes, traits, or properties
 */
class ActorTypeConfig extends CreatureTypeConfig {
  constructor(actor, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `ActorTypeConfig` application has been deprecated and replaced with `CreatureTypeConfig`.",
      { since: "DnD5e 4.1", until: "DnD5e 4.3" }
    );
    if ( options.keyPath ) options.keyPath = options.keyPath?.replace("system.", "");
    super({ ...options, document: actor });
  }
}

var _module$o = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityConfig: AbilityConfig,
  ActorAbilityConfig: ActorAbilityConfig,
  ActorArmorConfig: ActorArmorConfig,
  ActorConcentrationConfig: ActorConcentrationConfig,
  ActorHitDiceConfig: ActorHitDiceConfig,
  ActorHitPointsConfig: ActorHitPointsConfig,
  ActorInitiativeConfig: ActorInitiativeConfig,
  ActorMovementConfig: ActorMovementConfig,
  ActorSensesConfig: ActorSensesConfig,
  ActorSheet5e: ActorSheet5e,
  ActorSheet5eCharacter: ActorSheet5eCharacter,
  ActorSheet5eCharacter2: ActorSheet5eCharacter2,
  ActorSheet5eNPC: ActorSheet5eNPC,
  ActorSheet5eNPC2: ActorSheet5eNPC2,
  ActorSheet5eVehicle: ActorSheet5eVehicle,
  ActorSheetFlags: ActorSheetFlags,
  ActorSheetMixin: ActorSheetMixin,
  ActorSheetV2Mixin: ActorSheetV2Mixin,
  ActorSkillsConfig: ActorSkillsConfig,
  ActorSpellSlotsConfig: ActorSpellSlotsConfig,
  ActorTypeConfig: ActorTypeConfig,
  ArmorClassConfig: ArmorClassConfig,
  BaseConfigSheet: BaseConfigSheet,
  BaseConfigSheetV2: BaseConfigSheet$1,
  BaseProficiencyConfig: BaseProficiencyConfig,
  BaseRestDialog: BaseRestDialog,
  ConcentrationConfig: ConcentrationConfig,
  DamageModificationConfig: DamageModificationConfig,
  DamagesConfig: DamagesConfig,
  DeathConfig: DeathConfig,
  GroupActorSheet: GroupActorSheet,
  HabitatConfig: HabitatConfig,
  HitDiceConfig: HitDiceConfig,
  HitPointsConfig: HitPointsConfig,
  InitiativeConfig: InitiativeConfig,
  LanguagesConfig: LanguagesConfig,
  LongRestDialog: LongRestDialog,
  ProficiencyConfig: ProficiencyConfig,
  ShortRestDialog: ShortRestDialog,
  SkillToolConfig: SkillToolConfig,
  SkillsConfig: SkillsConfig,
  SpellSlotsConfig: SpellSlotsConfig,
  ToolSelector: ToolSelector,
  ToolsConfig: ToolsConfig,
  TraitSelector: TraitSelector,
  TraitsConfig: TraitsConfig,
  TreasureConfig: TreasureConfig,
  WeaponsConfig: WeaponsConfig
});

/**
 * Base configuration application for advancements that can be extended by other types to implement custom
 * editing interfaces.
 *
 * @param {Advancement} advancement            The advancement item being edited.
 * @param {object} [options={}]                Additional options passed to FormApplication.
 * @param {string} [options.dropKeyPath=null]  Path within advancement configuration where dropped items are stored.
 *                                             If populated, will enable default drop & delete behavior.
 */
class AdvancementConfig extends FormApplication {
  constructor(advancement, options={}) {
    super(advancement, options);
    this.#advancementId = advancement.id;
    this.item = advancement.item;
  }

  /* -------------------------------------------- */

  /**
   * The ID of the advancement being created or edited.
   * @type {string}
   */
  #advancementId;

  /* -------------------------------------------- */

  /**
   * Parent item to which this advancement belongs.
   * @type {Item5e}
   */
  item;

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e", "advancement", "dialog"],
      template: "systems/dnd5e/templates/advancement/advancement-config.hbs",
      width: 400,
      height: "auto",
      submitOnChange: true,
      closeOnSubmit: false,
      dropKeyPath: null
    });
  }

  /* -------------------------------------------- */

  /**
   * The advancement being created or edited.
   * @type {Advancement}
   */
  get advancement() {
    return this.item.advancement.byId[this.#advancementId];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    const type = this.advancement.constructor.metadata.title;
    return `${game.i18n.format("DND5E.AdvancementConfigureTitle", { item: this.item.name })}: ${type}`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async close(options={}) {
    await super.close(options);
    delete this.advancement?.apps[this.appId];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData() {
    const levels = Object.fromEntries(Array.fromRange(CONFIG.DND5E.maxLevel + 1).map(l => [l, l]));
    if ( ["class", "subclass"].includes(this.item.type) ) delete levels[0];
    else levels[0] = game.i18n.localize("DND5E.AdvancementLevelAnyHeader");
    const context = {
      appId: this.id,
      CONFIG: CONFIG.DND5E,
      ...this.advancement.toObject(false),
      src: this.advancement._source,
      source: this.advancement._source,
      default: {
        title: this.advancement.constructor.metadata.title,
        icon: this.advancement.constructor.metadata.icon,
        hint: ""
      },
      levels,
      classRestrictionOptions: [
        { value: "", label: game.i18n.localize("DND5E.AdvancementClassRestrictionNone") },
        { value: "primary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionPrimary") },
        { value: "secondary", label: game.i18n.localize("DND5E.AdvancementClassRestrictionSecondary") }
      ],
      showClassRestrictions: this.item.type === "class",
      showLevelSelector: !this.advancement.constructor.metadata.multiLevel
    };
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Perform any changes to configuration data before it is saved to the advancement.
   * @param {object} configuration  Configuration object.
   * @returns {object}              Modified configuration.
   */
  async prepareConfigurationUpdate(configuration) {
    return configuration;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);

    // Remove an item from the list
    if ( this.options.dropKeyPath ) html.on("click", "[data-action='delete']", this._onItemDelete.bind(this));

    for ( const element of html[0].querySelectorAll("multi-select") ) {
      element.addEventListener("change", this._onChangeInput.bind(this));
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  render(force=false, options={}) {
    this.advancement.apps[this.appId] = this;
    return super.render(force, options);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _updateObject(event, formData) {
    let updates = foundry.utils.expandObject(formData);
    if ( updates.configuration ) updates.configuration = await this.prepareConfigurationUpdate(updates.configuration);
    await this.advancement.update(updates);
  }

  /* -------------------------------------------- */

  /**
   * Helper method to take an object and apply updates that remove any empty keys.
   * @param {object} object  Object to be cleaned.
   * @returns {object}       Copy of object with only non false-ish values included and others marked
   *                         using `-=` syntax to be removed by update process.
   * @protected
   */
  static _cleanedObject(object) {
    return Object.entries(object).reduce((obj, [key, value]) => {
      let keep = false;
      if ( foundry.utils.getType(value) === "Object" ) {
        keep = Object.values(value).some(v => v);
      } else if ( value ) keep = true;
      if ( keep ) obj[key] = value;
      else obj[`-=${key}`] = null;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */
  /*  Drag & Drop for Item Pools                  */
  /* -------------------------------------------- */

  /**
   * Handle deleting an existing Item entry from the Advancement.
   * @param {Event} event        The originating click event.
   * @returns {Promise<Item5e>}  The updated parent Item after the application re-renders.
   * @protected
   */
  async _onItemDelete(event) {
    event.preventDefault();
    const uuidToDelete = event.currentTarget.closest("[data-item-uuid]")?.dataset.itemUuid;
    if ( !uuidToDelete ) return;
    const items = foundry.utils.getProperty(this.advancement.configuration, this.options.dropKeyPath);
    const updates = { configuration: await this.prepareConfigurationUpdate({
      [this.options.dropKeyPath]: items.filter(i => i.uuid !== uuidToDelete)
    }) };
    await this.advancement.update(updates);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragDrop() {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    if ( !this.options.dropKeyPath ) throw new Error(
      "AdvancementConfig#options.dropKeyPath must be configured or #_onDrop must be overridden to support"
      + " drag and drop on advancement config items."
    );

    // Try to extract the data
    const data = TextEditor.getDragEventData(event);

    if ( data?.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);

    try {
      this._validateDroppedItem(event, item);
    } catch(err) {
      ui.notifications.error(err.message);
      return null;
    }

    const existingItems = foundry.utils.getProperty(this.advancement.configuration, this.options.dropKeyPath);

    // Abort if this uuid is the parent item
    if ( item.uuid === this.item.uuid ) {
      ui.notifications.error("DND5E.ADVANCEMENT.ItemGrant.Warning.Recursive", {localize: true});
      return null;
    }

    // Abort if this uuid exists already
    if ( existingItems.find(i => i.uuid === item.uuid) ) {
      ui.notifications.warn("DND5E.ADVANCEMENT.ItemGrant.Warning.Duplicate", {localize: true});
      return null;
    }

    await this.advancement.update({[`configuration.${this.options.dropKeyPath}`]: [
      ...existingItems, { uuid: item.uuid }
    ]});
  }

  /* -------------------------------------------- */

  /**
   * Called when an item is dropped to validate the Item before it is saved. An error should be thrown
   * if the item is invalid.
   * @param {Event} event  Triggering drop event.
   * @param {Item5e} item  The materialized Item that was dropped.
   * @throws An error if the item is invalid.
   * @protected
   */
  _validateDroppedItem(event, item) {}

}

/**
 * Presents a list of advancement types to create when clicking the new advancement button.
 * Once a type is selected, this hands the process over to the advancement's individual editing interface.
 *
 * @param {Item5e} item             Item to which this advancement will be added.
 * @param {object} [dialogData={}]  An object of dialog data which configures how the modal window is rendered.
 * @param {object} [options={}]     Dialog rendering options.
 */
class AdvancementSelection extends Dialog {
  constructor(item, dialogData={}, options={}) {
    super(dialogData, options);

    /**
     * Store a reference to the Item to which this Advancement is being added.
     * @type {Item5e}
     */
    this.item = item;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e", "sheet", "advancement"],
      template: "systems/dnd5e/templates/advancement/advancement-selection.hbs",
      title: "DND5E.AdvancementSelectionTitle",
      width: 500,
      height: "auto"
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get id() {
    return `item-${this.item.id}-advancement-selection`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getData() {
    const context = { types: {} };
    for ( let [name, config] of Object.entries(CONFIG.DND5E.advancementTypes) ) {
      const advancement = config.documentClass;
      if ( config.hidden || !config.validItemTypes?.has(this.item.type) ) continue;
      context.types[name] = {
        label: advancement.metadata.title,
        icon: advancement.metadata.typeIcon,
        hint: advancement.metadata.hint,
        disabled: !advancement.availableForItem(this.item)
      };
    }
    context.types = dnd5e.utils.sortObjectEntries(context.types, "label");
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.on("change", "input", this._onChangeInput.bind(this));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onChangeInput(event) {
    const submit = this.element[0].querySelector("button[data-button='submit']");
    submit.disabled = !this.element[0].querySelector("input[name='type']:checked");
  }

  /* -------------------------------------------- */

  /**
   * A helper constructor function which displays the selection dialog and returns a Promise once its workflow has
   * been resolved.
   * @param {Item5e} item                         Item to which the advancement should be added.
   * @param {object} [config={}]
   * @param {boolean} [config.rejectClose=false]  Trigger a rejection if the window was closed without a choice.
   * @param {object} [config.options={}]          Additional rendering options passed to the Dialog.
   * @returns {Promise<AdvancementConfig|null>}   Result of `Item5e#createAdvancement`.
   */
  static async createDialog(item, { rejectClose=false, options={} }={}) {
    return new Promise((resolve, reject) => {
      const dialog = new this(item, {
        title: `${game.i18n.localize("DND5E.AdvancementSelectionTitle")}: ${item.name}`,
        buttons: {
          submit: {
            callback: html => {
              const formData = new FormDataExtended(html.querySelector("form"));
              const type = formData.get("type");
              resolve(item.createAdvancement(type));
            }
          }
        },
        close: () => {
          if ( rejectClose ) reject("No advancement type was selected");
          else resolve(null);
        }
      }, foundry.utils.mergeObject(options, { jQuery: false }));
      dialog.render(true);
    });
  }

}

var _module$n = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityScoreImprovementConfig: AbilityScoreImprovementConfig,
  AbilityScoreImprovementFlow: AbilityScoreImprovementFlow,
  AdvancementConfig: AdvancementConfig,
  AdvancementConfigV2: AdvancementConfig$1,
  AdvancementConfirmationDialog: AdvancementConfirmationDialog,
  AdvancementFlow: AdvancementFlow,
  AdvancementManager: AdvancementManager,
  AdvancementMigrationDialog: AdvancementMigrationDialog,
  AdvancementSelection: AdvancementSelection,
  HitPointsConfig: HitPointsConfig$1,
  HitPointsFlow: HitPointsFlow,
  ItemChoiceConfig: ItemChoiceConfig,
  ItemChoiceFlow: ItemChoiceFlow,
  ItemGrantConfig: ItemGrantConfig,
  ItemGrantFlow: ItemGrantFlow,
  ScaleValueConfig: ScaleValueConfig,
  ScaleValueFlow: ScaleValueFlow,
  SizeConfig: SizeConfig,
  SizeFlow: SizeFlow,
  SubclassFlow: SubclassFlow,
  TraitConfig: TraitConfig,
  TraitFlow: TraitFlow
});

var _module$m = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Application5e: Application5e,
  ApplicationV2Mixin: ApplicationV2Mixin,
  Dialog5e: Dialog5e,
  DocumentSheet5e: DocumentSheet5e,
  PseudoDocumentSheet: PseudoDocumentSheet
});

/**
 * @typedef {object} CombatGroupData
 * @property {boolean} expanded
 */

/**
 * An extension of the base CombatTracker class to provide some 5e-specific functionality.
 * @extends {CombatTracker}
 */
class CombatTracker5e extends (foundry.applications?.sidebar?.tabs?.CombatTracker ?? CombatTracker) {

  /** @inheritDoc */
  async getData(options={}) {
    const context = await super.getData(options);
    context.turns.forEach(turn => {
      turn.initiative = formatNumber(Number(turn.initiative), { maximumFractionDigits: 0 });
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareTrackerContext(context, options) {
    await super._prepareTrackerContext(context, options);
    context.turns?.forEach(turn => {
      if ( Number.isFinite(turn.initiative) ) {
        turn.initiative = formatNumber(Number(turn.initiative), { maximumFractionDigits: 0 });
      }
    });
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onCombatantControl(event, target) {
    const btn = target || event.currentTarget;
    const combatantId = btn.closest(".combatant").dataset.combatantId;
    const combatant = this.viewed.combatants.get(combatantId);
    const action = btn.dataset.control || btn.dataset.action;
    if ( (action === "rollInitiative") && combatant?.actor ) return combatant.actor.rollInitiativeDialog();
    return super._onCombatantControl(event, target);
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _contextMenu(html) {
    if ( !(html instanceof HTMLElement) ) html = html[0];
    new ContextMenu5e(
      html.querySelector(".directory-list"), ".directory-item:not(.combatant-group)", this._getEntryContextOptions()
    );
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getEntryContextOptions() {
    const options = super._getEntryContextOptions();
    options.forEach(o => {
      const condition = o.condition ?? (function() { return true; });
      o.condition = li => {
        const el = li instanceof HTMLElement ? li : li[0];
        return condition(li) && !el.matches(".combatant-group");
      };
    });
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Adjust initiative tracker to group combatants.
   * @param {HTMLElement} html  The combat tracker being rendered.
   */
  renderGroups(html) {
    if ( !this.viewed ) return;
    const groups = this.viewed.createGroups();
    const V13 = game.release.generation >= 13;
    const list = html.querySelector(".directory-list, .combat-tracker");
    for ( const [key, { combatants, expanded }] of groups.entries() ) {
      const children = list.querySelectorAll(Array.from(combatants).map(c => `[data-combatant-id="${c.id}"]`).join(", "));
      if ( !children.length ) continue;
      const groupContainer = document.createElement("li");
      groupContainer.classList.add("combatant", "combatant-group", "collapsible", "dnd5e2-collapsible");
      if ( !V13 ) groupContainer.classList.add("directory-item");
      if ( !expanded ) groupContainer.classList.add("collapsed");

      // Determine the count
      let activeEntry;
      for ( const [index, element] of children.entries() ) {
        if ( element.classList.contains("active") ) activeEntry = index;
      }
      let count = game.i18n.format(`DND5E.COMBATANT.Counted.${getPluralRules().select(children.length)}`, {
        number: formatNumber(children.length)
      });
      if ( activeEntry !== undefined ) {
        groupContainer.classList.add("active");
        count = game.i18n.format("DND5E.COMBAT.Group.ActiveCount", {
          combatants: count, current: formatNumber(activeEntry + 1)
        });
      }

      const name = combatants[0].token.baseActor.prototypeToken.name;
      const img = children[0].querySelector("img");
      groupContainer.innerHTML = `
        <div class="group-header flexrow">
          <img class="token-image" alt="${img.alt}" src="${img.src || img.dataset.src}">
          <div class="token-name flexcol">
            <${V13 ? "strong" : "h4"} class="name"></${V13 ? "strong" : "h4"}>
            <div class="group-numbers">${count}</div>
          </div>
          <div class="token-initiative">
            <i class="fa-solid fa-chevron-down fa-fw" inert></i>
          </div>
        </div>
        <div class="collapsible-content">
          <div class="wrapper">
            <ol class="group-children ${V13 ? "" : "directory-list"}"></ol>
          </div>
        </div>
      `;
      groupContainer.dataset.groupKey = key;
      groupContainer.querySelector(".name").innerText = game.i18n.format("DND5E.COMBAT.Group.Title", { name });
      children[0].before(groupContainer);
      groupContainer.querySelector(".group-children").replaceChildren(...children);
      groupContainer.addEventListener("click", event => {
        if ( event.target.closest(".collapsible-content") ) return;
        if ( groupContainer.classList.contains("collapsed") ) this.viewed.expandedGroups.add(key);
        else this.viewed.expandedGroups.delete(key);
        groupContainer.classList.toggle("collapsed");
      });
    }
  }
}

var _module$l = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CombatTracker5e: CombatTracker5e
});

/**
 * Adds functionality to a custom HTML element for caching its stylesheet and adopting it into its Shadow DOM, rather
 * than having each stylesheet duplicated per element.
 * @param {typeof HTMLElement} Base  The base class being mixed.
 * @returns {typeof AdoptedStyleSheetElement}
 */
function AdoptedStyleSheetMixin(Base) {
  return class AdoptedStyleSheetElement extends Base {
    /**
     * A map of cached stylesheets per Document root.
     * @type {WeakMap<WeakKey<Document>, CSSStyleSheet>}
     * @protected
     */
    static _stylesheets = new WeakMap();

    /**
     * The CSS content for this element.
     * @type {string}
     */
    static CSS = "";

    /* -------------------------------------------- */

    /** @inheritDoc */
    adoptedCallback() {
      const sheet = this._getStyleSheet();
      if ( sheet ) this._adoptStyleSheet(this._getStyleSheet());
    }

    /* -------------------------------------------- */

    /**
     * Retrieves the cached stylesheet, or generates a new one.
     * @returns {CSSStyleSheet}
     * @protected
     */
    _getStyleSheet() {
      let sheet = this.constructor._stylesheets.get(this.ownerDocument);
      if ( !sheet && this.ownerDocument.defaultView ) {
        sheet = new this.ownerDocument.defaultView.CSSStyleSheet();
        sheet.replaceSync(this.constructor.CSS);
        this.constructor._stylesheets.set(this.ownerDocument, sheet);
      }
      return sheet;
    }

    /* -------------------------------------------- */

    /**
     * Adopt the stylesheet into the Shadow DOM.
     * @param {CSSStyleSheet} sheet  The sheet to adopt.
     * @abstract
     */
    _adoptStyleSheet(sheet) {}
  };
}

/**
 * A custom checkbox implementation with more styling options.
 * @mixes AdoptedStyleSheetMixin
 * @extends {AbstractFormInputElement}
 */
class CheckboxElement extends AdoptedStyleSheetMixin(
  foundry.applications.elements.AbstractFormInputElement
) {
  constructor(...args) {
    super(...args);
    this._internals.role = "checkbox";
    this._value = this.getAttribute("value");
    this.#defaultValue = this._value;
    if ( this.constructor.useShadowRoot ) this.#shadowRoot = this.attachShadow({ mode: "closed" });
  }

  /* -------------------------------------------- */

  /** @override */
  static tagName = "dnd5e-checkbox";

  /* -------------------------------------------- */

  /**
   * Should a show root be created for this element?
   */
  static useShadowRoot = true;

  /* -------------------------------------------- */

  /** @override */
  static CSS = `
    :host {
      cursor: pointer;
      display: inline-block;
      width: var(--checkbox-size, 18px);
      height: var(--checkbox-size, 18px);
      aspect-ratio: 1;
    }

    :host(:disabled) { cursor: default; }

    :host > div {
      width: 100%;
      height: 100%;
      border-radius: var(--checkbox-border-radius, 3px);
      border: var(--checkbox-border-width, 2px) solid var(--checkbox-border-color, var(--dnd5e-color-gold));
      background: var(--checkbox-empty-color, transparent);
      box-sizing: border-box;
      position: relative;
    }

    :host :is(.checked, .disabled, .indeterminate) {
      display: none;
      height: 100%;
      width: 100%;
      align-items: center;
      justify-content: center;
      position: absolute;
      inset: 0;
    }

    :host([checked]) :is(.checked, .disabled, .indeterminate) {
      background: var(--checkbox-fill-color, var(--dnd5e-color-gold));
    }

    :host([checked]) .checked { display: flex; }
    :host([indeterminate]) .indeterminate { display: flex; }
    :host([indeterminate]) .checked { display: none; }
    :host(:disabled) .disabled { display: flex; }
    :host(:disabled) .checked { display: none; }
    :host(:disabled) .indeterminate { display: none; }
  `;

  /* -------------------------------------------- */

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  _controller;

  /* -------------------------------------------- */

  /**
   * The shadow root that contains the checkbox elements.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */
  /*  Element Properties                          */
  /* -------------------------------------------- */

  /**
   * The default value as originally specified in the HTML that created this object.
   * @type {string}
   */
  get defaultValue() {
    return this.#defaultValue;
  }

  #defaultValue;

  /* -------------------------------------------- */

  /**
   * The indeterminate state of the checkbox.
   * @type {boolean}
   */
  get indeterminate() {
    return this.hasAttribute("indeterminate");
  }

  set indeterminate(indeterminate) {
    this.toggleAttribute("indeterminate", indeterminate);
  }

  /* -------------------------------------------- */

  /**
   * The checked state of the checkbox.
   * @type {boolean}
   */
  get checked() {
    return this.hasAttribute("checked");
  }

  set checked(checked) {
    this.toggleAttribute("checked", checked);
    this._refresh();
  }

  /* -------------------------------------------- */

  /** @override */
  get value() {
    return super.value;
  }

  /**
   * Override AbstractFormInputElement#value setter because we want to emit input/change events when the checked state
   * changes, and not when the value changes.
   * @override
   */
  set value(value) {
    this._setValue(value);
  }

  /** @override */
  _getValue() {
    // Workaround for FormElementExtended only checking the value property and not the checked property.
    if ( typeof this._value === "string" ) return this._value;
    return this.checked;
  }

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /** @override */
  connectedCallback() {
    this._adoptStyleSheet(this._getStyleSheet());
    const elements = this._buildElements();
    this.#shadowRoot.replaceChildren(...elements);
    this._refresh();
    this._activateListeners();
    if ( !this.hasAttribute("tabindex") ) this.tabIndex = 0;
  }

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this._controller.abort();
  }

  /* -------------------------------------------- */

  /** @override */
  _adoptStyleSheet(sheet) {
    this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    const container = document.createElement("div");
    container.innerHTML = `
      <div class="checked">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"
             style="fill: var(--checkbox-icon-color, #000); width: var(--checkbox-icon-size, 68%);">
          <!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/>
        </svg>
      </div>
      <div class="disabled">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"
             style="fill: var(--checkbox-icon-color, #000); width: var(--checkbox-icon-size, 68%);">
          <!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path d="M144 144v48H304V144c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192V144C80 64.5 144.5 0 224 0s144 64.5 144 144v48h16c35.3 0 64 28.7 64 64V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64H80z"/>
        </svg>
      </div>
      <div class="indeterminate">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"
             style="fill: var(--checkbox-icon-color, #000); width: var(--checkbox-icon-size, 68%);">
          <!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.-->
          <path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/>
        </svg>
      </div>
    `;
    return [container];
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const { signal } = this._controller = new AbortController();
    this.addEventListener("click", this._onClick.bind(this), { signal });
    this.addEventListener("keydown", event => event.key === " " ? this._onClick(event) : null, { signal });
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    super._refresh();
    this._internals.ariaChecked = `${this.hasAttribute("checked")}`;
  }

  /* -------------------------------------------- */

  /** @override */
  _onClick(event) {
    event.preventDefault();
    this.checked = !this.checked;
    this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
    this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
  }
}

/**
 * Bit of text with a button after it for copying it.
 */
class CopyableTextElement extends HTMLElement {
  /** @override */
  connectedCallback() {
    this.#controller = new AbortController();
    const button = document.createElement("button");
    button.ariaLabel = this.getAttribute("label") ?? game.i18n.localize("DND5E.Copy");
    button.classList.add("copy-button");
    button.dataset.tooltip = button.ariaLabel;
    button.innerHTML = '<i class="fa-regular fa-clipboard" inert></i>';
    this.addEventListener("click", this._onClick.bind(this), { signal: this.#controller.signal });
    this.append(button);
  }

  /* -------------------------------------------- */

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  #controller;

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this.#controller.abort();
    this.querySelector("button")?.remove();
  }

  /* -------------------------------------------- */

  /**
   * Handle copying the contents.
   * @param {PointerEvent} event  Triggering click event.
   */
  _onClick(event) {
    event.preventDefault();
    event.stopPropagation();
    const value = this.getAttribute("value") ?? this.innerText;
    game.clipboard.copyPlainText(value);
    game.tooltip.activate(event.target, { text: game.i18n.format("DND5E.Copied", { value }), direction: "UP" });
  }
}

/**
 * Custom element designed to display as a collapsible tray in chat.
 */
class ChatTrayElement extends HTMLElement {

  static observedAttributes = ["open"];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Is the tray expanded or collapsed?
   * @type {boolean}
   */
  get open() {
    return this.hasAttribute("open");
  }

  set open(open) {
    if ( open ) this.setAttribute("open", "");
    else this.removeAttribute("open");
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @override */
  attributeChangedCallback(name, oldValue, newValue) {
    if ( name === "open" ) this._handleToggleOpen(newValue !== null);
  }

  /* -------------------------------------------- */

  /**
   * Handle clicks to the collapsible header.
   * @param {PointerEvent} event  Triggering click event.
   */
  _handleClickHeader(event) {
    event.preventDefault();
    event.stopImmediatePropagation();
    if ( !event.target.closest(".collapsible-content") ) this.toggleAttribute("open");
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the collapsed state of this element.
   * @param {boolean} open  Is the element open?
   */
  _handleToggleOpen(open) {
    this.dispatchEvent(new Event("toggle"));

    this.querySelector(".collapsible")?.classList.toggle("collapsed", !open);

    // Clear the height from the chat popout container so that it appropriately resizes.
    const popout = this.closest(".chat-popout");
    if ( popout ) popout.style.height = "";
  }
}

/**
 * Adds functionality to a custom HTML element for displaying a target selector and displaying targets.
 * @param {typeof HTMLElement} Base  The base class being mixed.
 * @returns {typeof TargetedApplicationElement}
 */
function TargetedApplicationMixin(Base) {
  return class TargetedApplicationElement extends Base {

    /* -------------------------------------------- */
    /*  Properties                                  */
    /* -------------------------------------------- */

    /**
     * Currently registered hook for monitoring for changes to selected tokens.
     * @type {number|null}
     */
    selectedTokensHook = null;

    /* -------------------------------------------- */

    /**
     * Currently target selection mode.
     * @type {"targeted"|"selected"}
     */
    get targetingMode() {
      if ( this.targetSourceControl.hidden ) return "selected";
      return this.targetSourceControl.querySelector('[aria-pressed="true"]')?.dataset.mode ?? "targeted";
    }

    set targetingMode(mode) {
      if ( this.targetSourceControl.hidden ) mode = "selected";
      const toPress = this.targetSourceControl.querySelector(`[data-mode="${mode}"]`);
      const currentlyPressed = this.targetSourceControl.querySelector('[aria-pressed="true"]');
      if ( currentlyPressed ) currentlyPressed.ariaPressed = false;
      toPress.ariaPressed = true;

      this.buildTargetsList();
      if ( (mode === "targeted") && (this.selectedTokensHook !== null) ) {
        Hooks.off("controlToken", this.selectedTokensHook);
        this.selectedTokensHook = null;
      } else if ( (mode === "selected") && (this.selectedTokensHook === null) ) {
        this.selectedTokensHook = Hooks.on("controlToken", foundry.utils.debounce(() => this.buildTargetsList(), 50));
      }
    }

    /* -------------------------------------------- */

    /**
     * The list of application targets.
     * @type {HTMLUListElement}
     */
    targetList;

    /* -------------------------------------------- */

    /**
     * The controls for selecting target source mode.
     * @type {HTMLElement}
     */
    targetSourceControl;

    /* -------------------------------------------- */
    /*  Rendering                                   */
    /* -------------------------------------------- */

    /**
     * Return the HTML elements needed to build the target source control and target list.
     * @returns {HTMLElement[]}
     */
    buildTargetContainer() {
      this.targetSourceControl = document.createElement("div");
      this.targetSourceControl.classList.add("target-source-control");
      this.targetSourceControl.innerHTML = `
        <button type="button" class="unbutton" data-mode="targeted" aria-pressed="false">
          <i class="fa-solid fa-bullseye" inert></i> ${game.i18n.localize("DND5E.Tokens.Targeted")}
        </button>
        <button type="button" class="unbutton" data-mode="selected" aria-pressed="false">
          <i class="fa-solid fa-expand" inert></i> ${game.i18n.localize("DND5E.Tokens.Selected")}
        </button>
      `;
      this.targetSourceControl.querySelectorAll("button").forEach(b =>
        b.addEventListener("click", this._onChangeTargetMode.bind(this))
      );
      if ( !this.chatMessage?.getFlag("dnd5e", "targets")?.length ) this.targetSourceControl.hidden = true;

      this.targetList = document.createElement("ul");
      this.targetList.classList.add("targets", "unlist");

      return [this.targetSourceControl, this.targetList];
    }

    /* -------------------------------------------- */

    /**
     * Build a list of targeted tokens based on current mode & replace any existing targets.
     */
    buildTargetsList() {
      if ( !this.targetList ) throw new Error("Must create a element to contain the target list.");
      const targetedTokens = new Map();
      switch ( this.targetingMode ) {
        case "targeted":
          this.chatMessage?.getFlag("dnd5e", "targets")?.forEach(t => targetedTokens.set(t.uuid, t.name));
          break;
        case "selected":
          canvas.tokens?.controlled?.forEach(t => targetedTokens.set(t.actor.uuid, t.name));
          break;
      }
      const targets = Array.from(targetedTokens.entries())
        .map(([uuid, name]) => this.buildTargetListEntry({ uuid, name }))
        .filter(t => t);
      if ( targets.length ) this.targetList.replaceChildren(...targets);
      else {
        const li = document.createElement("li");
        li.classList.add("none");
        li.innerText = game.i18n.localize(`DND5E.Tokens.None${this.targetingMode.capitalize()}`);
        this.targetList.replaceChildren(li);
      }
    }

    /* -------------------------------------------- */

    /**
     * Create a list entry for a single target.
     * @param {object} data
     * @param {string} data.uuid  UUID of the targeted actor.
     * @param {string} data.name  Name of the targeted token.
     * @returns {HTMLLIElement|void}
     * @abstract
     */
    buildTargetListEntry({ uuid, name }) {}

    /* -------------------------------------------- */
    /*  Event Handlers                              */
    /* -------------------------------------------- */

    /**
     * Handle clicking on the target mode buttons.
     * @param {PointerEvent} event  Triggering click event.
     */
    async _onChangeTargetMode(event) {
      event.preventDefault();
      this.targetingMode = event.currentTarget.dataset.mode;
    }
  };
}

/**
 * List of multiplier options as tuples containing their numeric value and rendered text.
 * @type {[number, string][]}
 */
const MULTIPLIERS = [[-1, "-1"], [0, "0"], [.25, "¼"], [.5, "½"], [1, "1"], [2, "2"]];

/**
 * Application to handle applying damage from a chat card.
 */
class DamageApplicationElement extends TargetedApplicationMixin(ChatTrayElement) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The apply damage button within the element.
   * @type {HTMLButtonElement}
   */
  applyButton;

  /* -------------------------------------------- */

  /**
   * The chat message with which this damage is associated.
   * @type {ChatMessage5e}
   */
  chatMessage;

  /* -------------------------------------------- */

  /**
   * Damage descriptions that will be applied by this application.
   * @type {DamageDescription[]}
   */
  damages = [];

  /* -------------------------------------------- */

  /**
   * Options for each application target.
   * @type {Map<string, DamageApplicationOptions>}
   */
  #targetOptions = new Map();

  /**
   * Options for a specific target.
   * @param {string} uuid  UUID of the targeted token.
   * @returns {DamageApplicationOptions}
   */
  getTargetOptions(uuid) {
    if ( !this.#targetOptions.has(uuid) ) this.#targetOptions.set(uuid, { multiplier: 1 });
    return this.#targetOptions.get(uuid);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  connectedCallback() {
    // Fetch the associated chat message
    const messageId = this.closest("[data-message-id]")?.dataset.messageId;
    this.chatMessage = game.messages.get(messageId);
    if ( !this.chatMessage ) return;

    // Build the frame HTML only once
    if ( !this.targetList ) {
      const div = document.createElement("div");
      div.classList.add("card-tray", "damage-tray", "collapsible");
      if ( !this.open ) div.classList.add("collapsed");
      div.innerHTML = `
        <label class="roboto-upper">
          <i class="fa-solid fa-heart-crack"></i>
          <span>${game.i18n.localize("DND5E.Apply")}</span>
          <i class="fa-solid fa-caret-down"></i>
        </label>
        <div class="collapsible-content">
          <div class="wrapper">
            <button class="apply-damage" type="button" data-action="applyDamage">
              <i class="fa-solid fa-reply-all fa-flip-horizontal" inert></i>
              ${game.i18n.localize("DND5E.Apply")}
            </button>
          </div>
        </div>
      `;
      this.replaceChildren(div);
      this.applyButton = div.querySelector(".apply-damage");
      this.applyButton.addEventListener("click", this._onApplyDamage.bind(this));
      div.querySelector(".wrapper").prepend(...this.buildTargetContainer());
      div.addEventListener("click", this._handleClickHeader.bind(this));
    }

    this.targetingMode = this.targetSourceControl.hidden ? "selected" : "targeted";
  }

  /* -------------------------------------------- */

  /** @override */
  buildTargetListEntry({ uuid, name }) {
    const actor = fromUuidSync(uuid);
    if ( !actor?.isOwner ) return;

    // Calculate damage to apply
    const targetOptions = this.getTargetOptions(uuid);
    const { temp, total, active } = this.calculateDamage(actor, targetOptions);

    const types = [];
    for ( const [change, values] of Object.entries(active) ) {
      for ( const type of values ) {
        const config = CONFIG.DND5E.damageTypes[type] ?? CONFIG.DND5E.healingTypes[type];
        if ( !config ) continue;
        const data = { type, change, icon: config.icon };
        types.push(data);
      }
    }
    const changeSources = types.reduce((acc, {type, change, icon}) => {
      const { label, pressed } = this.getChangeSourceOptions(type, change, targetOptions);
      acc += `
        <button class="change-source unbutton" type="button" data-type="${type}" data-change="${change}"
                data-tooltip="${label}" aria-label="${label}" aria-pressed="${pressed}">
          <dnd5e-icon src="${icon}" inert></dnd5e-icon>
          <i class="fa-solid fa-slash" inert></i>
          <i class="fa-solid fa-arrow-turn-down" inert></i>
        </button>
      `;
      return acc;
    }, "");

    const li = document.createElement("li");
    li.classList.add("target");
    li.dataset.targetUuid = uuid;
    li.innerHTML = `
      <img class="gold-icon">
      <div class="name-stacked">
        <span class="title"></span>
        ${changeSources ? `<span class="subtitle">${changeSources}</span>` : ""}
      </div>
      <div class="calculated damage">
        ${total}
      </div>
      <div class="calculated temp" data-tooltip="DND5E.HitPointsTemp">
        ${temp}
      </div>
      <menu class="damage-multipliers unlist"></menu>
    `;
    Object.assign(li.querySelector(".gold-icon"), { alt: name, src: actor.img });
    li.querySelector(".name-stacked .title").append(name);
    const menu = li.querySelector("menu");
    for ( const [value, display] of MULTIPLIERS ) {
      const entry = document.createElement("li");
      entry.innerHTML = `
        <button class="multiplier-button" type="button" value="${value}">
          <span>${display}</span>
        </button>
      `;
      menu.append(entry);
    }

    this.refreshListEntry(actor, li, targetOptions);
    li.addEventListener("click", this._onChangeOptions.bind(this));

    return li;
  }

  /* -------------------------------------------- */

  /**
   * Calculate the total damage that will be applied to an actor.
   * @param {Actor5e} actor
   * @param {DamageApplicationOptions} options
   * @returns {{temp: number, total: number, active: Record<string, Set<string>>}}
   */
  calculateDamage(actor, options) {
    const damages = actor.calculateDamage(this.damages, options);

    let temp = 0;
    let total = 0;
    let active = { modification: new Set(), resistance: new Set(), vulnerability: new Set(), immunity: new Set() };
    for ( const damage of damages ) {
      if ( damage.type === "temphp" ) temp += damage.value;
      else total += damage.value;
      if ( damage.active.modification ) active.modification.add(damage.type);
      if ( damage.active.resistance ) active.resistance.add(damage.type);
      if ( damage.active.vulnerability ) active.vulnerability.add(damage.type);
      if ( damage.active.immunity ) active.immunity.add(damage.type);
    }
    temp = Math.floor(Math.max(0, temp));
    total = total > 0 ? Math.floor(total) : Math.ceil(total);

    // Add values from options to prevent active changes from being lost when re-rendering target list
    const union = t => {
      if ( foundry.utils.getType(options.ignore?.[t]) === "Set" ) active[t] = active[t].union(options.ignore[t]);
    };
    union("modification");
    union("resistance");
    union("vulnerability");
    union("immunity");
    if ( foundry.utils.getType(options.downgrade) === "Set" ) {
      active.immunity = active.immunity.union(options.downgrade);
    }

    return { temp, total, active };
  }

  /* -------------------------------------------- */

  /**
   * Get the label and pressed value for a specific change source.
   * @param {string} type                       Damage type represented by this source.
   * @param {string} change                     Change type (e.g. resistance, immunity, etc.).
   * @param {DamageApplicationOptions} options  Options object from which to determine final values.
   * @returns {{label: string, pressed: string}}
   */
  getChangeSourceOptions(type, change, options) {
    let mode = "active";
    if ( options.ignore?.[change]?.has(type) ) mode = "ignore";
    else if ( (change === "immunity") && options.downgrade?.has(type) ) mode = "downgrade";

    let label = game.i18n.format(`DND5E.DamageApplication.Change.${change.capitalize()}`, {
      type: CONFIG.DND5E.damageTypes[type]?.label ?? CONFIG.DND5E.healingTypes[type]?.label
    });
    if ( mode === "ignore" ) label = game.i18n.format("DND5E.DamageApplication.Ignoring", { source: label });
    if ( mode === "downgrade" ) label = game.i18n.format("DND5E.DamageApplication.Downgrading", { source: label });

    return { label, pressed: mode === "active" ? "false" : mode === "ignore" ? "true" : "mixed" };
  }

  /* -------------------------------------------- */

  /**
   * Refresh the damage total on a list entry based on modified options.
   * @param {Actor5e} token
   * @param {HTMLLiElement} entry
   * @param {DamageApplicationOptions} options
   */
  refreshListEntry(token, entry, options) {
    const { temp, total } = this.calculateDamage(token, options);
    const calculatedDamage = entry.querySelector(".calculated.damage");
    calculatedDamage.innerText = formatNumber(-total, { signDisplay: "exceptZero" });
    calculatedDamage.classList.toggle("healing", total < 0);
    calculatedDamage.dataset.tooltip = `DND5E.${total < 0 ? "Healing" : "Damage"}`;
    calculatedDamage.hidden = !total && !!temp;
    const calculatedTemp = entry.querySelector(".calculated.temp");
    calculatedTemp.innerText = temp;
    calculatedTemp.hidden = !temp;

    const pressedMultiplier = entry.querySelector('.multiplier-button[aria-pressed="true"]');
    if ( Number(pressedMultiplier?.dataset.multiplier) !== options.multiplier ) {
      if ( pressedMultiplier ) pressedMultiplier.ariaPressed = false;
      const toPress = entry.querySelector(`[value="${options.multiplier}"]`);
      if ( toPress ) toPress.ariaPressed = true;
    }

    for ( const element of entry.querySelectorAll(".change-source") ) {
      const { type, change } = element.dataset;
      const { label, pressed } = this.getChangeSourceOptions(type, change, options);
      element.dataset.tooltip = label;
      element.ariaLabel = label;
      element.ariaPressed = pressed;
    }
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle clicking the apply damage button.
   * @param {PointerEvent} event  Triggering click event.
   */
  async _onApplyDamage(event) {
    event.preventDefault();
    for ( const target of this.targetList.querySelectorAll("[data-target-uuid]") ) {
      const token = fromUuidSync(target.dataset.targetUuid);
      const options = this.getTargetOptions(target.dataset.targetUuid);
      await token?.applyDamage(this.damages, options);
    }
    this.open = false;
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking a multiplier button or resistance toggle.
   * @param {PointerEvent} event  Triggering click event.
   */
  async _onChangeOptions(event) {
    event.preventDefault();
    const button = event.target.closest("button");
    const uuid = event.target.closest("[data-target-uuid]")?.dataset.targetUuid;
    if ( !uuid || !button ) return;

    const options = this.getTargetOptions(uuid);

    // Set multiplier
    if ( button.classList.contains("multiplier-button") ) {
      options.multiplier = Number(button.value);
    }

    // Set imm/res/vul ignore & downgrade
    else if ( button.classList.contains("change-source") ) {
      const { type, change } = button.dataset;
      if ( change === "immunity" ) {
        if ( options.ignore?.immunity?.has(type) ) {
          options.ignore.immunity.delete(type);
          options.downgrade ??= new Set();
          options.downgrade.add(type);
        } else if ( options.downgrade?.has(type) ) {
          options.downgrade.delete(type);
        } else {
          options.ignore ??= {};
          options.ignore[change] ??= new Set();
          options.ignore[change].add(type);
        }
      }
      else if ( options.ignore?.[change]?.has(type) ) options.ignore[change].delete(type);
      else {
        options.ignore ??= {};
        options.ignore[change] ??= new Set();
        options.ignore[change].add(type);
      }
    }

    const token = fromUuidSync(uuid);
    const entry = this.targetList.querySelector(`[data-target-uuid="${token.uuid}"]`);
    this.refreshListEntry(token, entry, options);
  }
}

/**
 * Application to handle applying active effects from a chat card.
 */
class EffectApplicationElement extends TargetedApplicationMixin(ChatTrayElement) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The chat message with which this application is associated.
   * @type {ChatMessage5e}
   */
  chatMessage;

  /* -------------------------------------------- */

  /**
   * Active effects that will be applied by this application.
   * @type {ActiveEffect5e[]}
   */
  effects = [];

  /* -------------------------------------------- */

  /**
   * The list of active effects.
   * @type {HTMLUListElement}
   */
  effectsList;

  /* -------------------------------------------- */

  /**
   * Checked status for application targets.
   * @type {Map<string, boolean>}
   */
  #targetOptions = new Map();

  /**
   * Options for a specific target.
   * @param {string} uuid  UUID of the target.
   * @returns {boolean}    Should this target be checked?
   */
  targetChecked(uuid) {
    if ( this.targetingMode === "selected" ) return true;
    return this.#targetOptions.get(uuid) ?? true;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  connectedCallback() {
    // Fetch the associated chat message
    const messageId = this.closest("[data-message-id]")?.dataset.messageId;
    this.chatMessage = game.messages.get(messageId);
    if ( !this.chatMessage ) return;

    // Build the frame HTML only once
    if ( !this.effectsList || !this.targetList ) {
      const div = document.createElement("div");
      div.classList.add("card-tray", "effects-tray", "collapsible");
      if ( !this.open ) div.classList.add("collapsed");
      div.innerHTML = `
        <label class="roboto-upper">
          <i class="fa-solid fa-bolt"></i>
          <span>${game.i18n.localize("DND5E.Effects")}</span>
          <i class="fa-solid fa-caret-down"></i>
        </label>
        <div class="collapsible-content">
          <div class="wrapper">
            <hr>
            <menu class="effects unlist"></menu>
          </div>
        </div>
      `;
      this.replaceChildren(div);
      this.effectsList = div.querySelector(".effects");
      this.buildEffectsList();
      div.querySelector(".wrapper").prepend(...this.buildTargetContainer());
      this.targetList.addEventListener("change", this._onCheckTarget.bind(this));
      div.addEventListener("click", this._handleClickHeader.bind(this));
    }

    this.targetingMode = this.targetSourceControl.hidden ? "selected" : "targeted";
  }

  /* -------------------------------------------- */

  /**
   * Build a list of active effects.
   */
  buildEffectsList() {
    for ( const effect of this.effects ) {
      const li = document.createElement("li");
      li.classList.add("effect");
      li.dataset.id = effect.id;
      li.innerHTML = `
        <img class="gold-icon">
        <div class="name-stacked">
          <span class="title"></span>
          <span class="subtitle">${effect.duration.label}</span>
        </div>
        <button class="apply-effect" type="button" data-action="applyEffect"
                data-tooltip="DND5E.EffectsApplyTokens"
                aria-label="${game.i18n.localize("DND5E.EffectsApplyTokens")}">
          <i class="fas fa-reply-all fa-flip-horizontal" inert></i>
        </button>
      `;
      Object.assign(li.querySelector(".gold-icon"), { alt: effect.name, src: effect.img });
      li.querySelector(".name-stacked .title").append(effect.name);
      this.effectsList.append(li);
      li.addEventListener("click", this._onApplyEffect.bind(this));
    }
  }

  /* -------------------------------------------- */

  /** @override */
  buildTargetListEntry({ uuid, name }) {
    const actor = fromUuidSync(uuid);
    if ( !actor?.isOwner ) return;

    const disabled = this.targetingMode === "selected" ? " disabled" : "";
    const checked = this.targetChecked(uuid) ? " checked" : "";

    const li = document.createElement("li");
    li.classList.add("target");
    li.dataset.targetUuid = uuid;
    li.innerHTML = `
      <img class="gold-icon">
      <div class="name-stacked">
        <span class="title"></span>
      </div>
      <div class="checkbox">
        <dnd5e-checkbox name="${uuid}"${checked}${disabled}></dnd5e-checkbox>
      </div>
    `;
    Object.assign(li.querySelector(".gold-icon"), { alt: name, src: actor.img });
    li.querySelector(".name-stacked .title").append(name);

    return li;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle applying an Active Effect to a Token.
   * @param {ActiveEffect5e} effect      The effect to apply.
   * @param {Actor5e} actor              The actor.
   * @returns {Promise<ActiveEffect5e>}  The created effect.
   * @throws {Error}                     If the effect could not be applied.
   * @protected
   */
  async _applyEffectToActor(effect, actor) {
    const concentration = this.chatMessage.getAssociatedActor()?.effects
      .get(this.chatMessage.getFlag("dnd5e", "use.concentrationId"));
    const origin = concentration ?? effect;
    if ( !game.user.isGM && !actor.isOwner ) {
      throw new Error(game.i18n.localize("DND5E.EffectApplyWarningOwnership"));
    }

    const effectFlags = {
      flags: {
        dnd5e: {
          scaling: this.chatMessage.getFlag("dnd5e", "scaling"),
          spellLevel: this.chatMessage.getFlag("dnd5e", "use.spellLevel")
        }
      }
    };

    // Enable an existing effect on the target if it originated from this effect
    const existingEffect = actor.effects.find(e => e.origin === origin.uuid);
    if ( existingEffect ) {
      return existingEffect.update(foundry.utils.mergeObject({
        ...effect.constructor.getInitialDuration(),
        disabled: false
      }, effectFlags));
    }

    if ( !game.user.isGM && concentration && !concentration.actor?.isOwner ) {
      throw new Error(game.i18n.localize("DND5E.EffectApplyWarningConcentration"));
    }

    // Otherwise, create a new effect on the target
    const effectData = foundry.utils.mergeObject({
      ...effect.toObject(),
      disabled: false,
      transfer: false,
      origin: origin.uuid
    }, effectFlags);
    const applied = await ActiveEffect.implementation.create(effectData, { parent: actor });
    if ( concentration ) await concentration.addDependent(applied);
    return applied;
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking the apply effect button.
   * @param {PointerEvent} event  Triggering click event.
   */
  async _onApplyEffect(event) {
    event.preventDefault();
    const effect = this.chatMessage.getAssociatedItem()?.effects.get(event.target.closest("[data-id]")?.dataset.id);
    if ( !effect ) return;
    for ( const target of this.targetList.querySelectorAll("[data-target-uuid]") ) {
      const actor = fromUuidSync(target.dataset.targetUuid);
      if ( !actor || !target.querySelector("dnd5e-checkbox")?.checked ) continue;
      try {
        await this._applyEffectToActor(effect, actor);
      } catch(err) {
        Hooks.onError("EffectApplicationElement._applyEffectToToken", err, { notify: "warn", log: "warn" });
      }
    }
    this.querySelector(".collapsible").dispatchEvent(new PointerEvent("click", { bubbles: true, cancelable: true }));
  }

  /* -------------------------------------------- */

  /**
   * Handle checking or unchecking a target.
   * @param {Event} event  Triggering change event.
   */
  _onCheckTarget(event) {
    const uuid = event.target.closest("[data-target-uuid]")?.dataset.targetUuid;
    if ( !uuid ) return;
    this.#targetOptions.set(uuid, event.target.checked);
  }
}

/**
 * Application to handle applying enchantments to items from a chat card.
 */
class EnchantmentApplicationElement extends HTMLElement {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The chat message with which this enchantment is associated.
   * @type {ChatMessage5e}
   */
  chatMessage;

  /* -------------------------------------------- */

  /**
   * Area where the enchantment limit & current count is displayed.
   * @type {HTMLElement}
   */
  countArea;

  /* -------------------------------------------- */

  /**
   * Area where items can be dropped to enchant.
   * @type {HTMLElement}
   */
  dropArea;

  /* -------------------------------------------- */

  /**
   * Activity providing the enchantment that will be applied.
   * @type {Item5e}
   */
  get enchantmentActivity() {
    return this.chatMessage.getAssociatedActivity();
  }

  /* -------------------------------------------- */

  /**
   * Item providing the enchantment that will be applied.
   * @type {Item5e}
   */
  get enchantmentItem() {
    return this.chatMessage.getAssociatedItem();
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  connectedCallback() {
    const messageId = this.closest("[data-message-id]")?.dataset.messageId;
    this.chatMessage = game.messages.get(messageId);
    if ( !this.chatMessage ) return;

    // Build the frame HTML only once
    if ( !this.dropArea ) {
      const div = document.createElement("div");
      div.classList.add("enchantment-control");
      div.innerHTML = '<div class="drop-area"></div>';
      this.replaceChildren(div);
      this.dropArea = div.querySelector(".drop-area");
      this.addEventListener("drop", this._onDrop.bind(this));
      this.addEventListener("click", this._onRemoveEnchantment.bind(this));
    }

    // Calculate the maximum targets
    let item = this.enchantmentItem;
    const scaling = this.chatMessage.getFlag("dnd5e", "scaling");
    if ( scaling ) item = item.clone({ "flags.dnd5e.scaling": scaling });
    const activity = item.system.activities.get(this.enchantmentActivity.id);
    const maxTargets = activity.target?.affects?.count;
    if ( maxTargets ) {
      if ( !this.countArea ) {
        const div = document.createElement("div");
        div.classList.add("count-area");
        this.querySelector(".enchantment-control").append(div);
        this.countArea = this.querySelector(".count-area");
      }
      this.countArea.innerHTML = game.i18n.format("DND5E.ENCHANT.Enchanted", {
        current: '<span class="current">0</span>',
        max: `<span class="max">${maxTargets}<span>`
      });
    } else if ( this.countArea ) {
      this.countArea.remove();
    }

    this.buildItemList();
  }

  /* -------------------------------------------- */

  /**
   * Build a list of enchanted items. Will be called whenever the enchanted items are changed in order to update
   * the card list.
   */
  buildItemList() {
    const enchantedItems = dnd5e.registry.enchantments.applied(this.enchantmentActivity.uuid).map(enchantment => {
      const item = enchantment.parent;
      const div = document.createElement("div");
      div.classList.add("preview");
      div.dataset.enchantmentUuid = enchantment.uuid;
      div.innerHTML = `
        <img class="gold-icon">
        <span class="name"></span>
      `;
      Object.assign(div.querySelector("img"), { alt: item.name, src: item.img });
      div.querySelector(".name").append(item.name);
      if ( item.isOwner ) {
        const control = document.createElement("a");
        control.ariaLabel = game.i18n.localize("DND5E.ENCHANTMENT.Action.Remove");
        control.dataset.action = "removeEnchantment";
        control.dataset.tooltip = "DND5E.ENCHANTMENT.Action.Remove";
        control.innerHTML = '<i class="fa-solid fa-rotate-left" inert></i>';
        div.append(control);
      }
      return div;
    });
    if ( enchantedItems.length ) this.dropArea.replaceChildren(...enchantedItems);
    else this.dropArea.innerHTML = `<p>${game.i18n.localize("DND5E.ENCHANT.DropArea")}</p>`;
    if ( this.countArea ) this.countArea.querySelector(".current").innerText = enchantedItems.length;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle dropping an item onto the control.
   * @param {Event} event  Triggering drop event.
   */
  async _onDrop(event) {
    event.preventDefault();
    const data = TextEditor.getDragEventData(event);
    const effect = this.enchantmentItem.effects.get(this.chatMessage.getFlag("dnd5e", "use.enchantmentProfile"));
    if ( (data.type !== "Item") || !effect ) return;
    const droppedItem = await Item.implementation.fromDropData(data);

    // Validate against the enchantment's restraints on the origin item
    const errors = this.enchantmentActivity.canEnchant(droppedItem);
    if ( errors?.length ) {
      errors.forEach(err => ui.notifications.error(err.message, { console: false }));
      return;
    }

    // If concentration is required, ensure it is still being maintained & GM is present
    const concentrationId = this.chatMessage.getFlag("dnd5e", "use.concentrationId");
    const concentration = effect.parent.actor.effects.get(concentrationId);
    if ( concentrationId && !concentration ) {
      ui.notifications.error("DND5E.ENCHANT.Warning.ConcentrationEnded", { console: false, localize: true });
      return;
    }
    if ( !game.user.isGM && concentration && !concentration.actor?.isOwner ) {
      ui.notifications.error("DND5E.EffectApplyWarningConcentration", { console: false, localize: true });
      return;
    }

    const effectData = effect.toObject();
    effectData.origin = this.enchantmentActivity.uuid;
    const applied = await ActiveEffect.create(effectData, {
      parent: droppedItem, keepOrigin: true, chatMessageOrigin: this.chatMessage.id
    });
    if ( concentration ) await concentration.addDependent(applied);
  }

  /* -------------------------------------------- */

  /**
   * Handle removing an enchantment.
   * @param {Event} event  Triggering drop event.
   */
  async _onRemoveEnchantment(event) {
    if ( event.target.dataset.action !== "removeEnchantment" ) return;
    const enchantmentUuid = event.target.closest("[data-enchantment-uuid]")?.dataset.enchantmentUuid;
    const enchantment = await fromUuid(enchantmentUuid);
    enchantment?.delete({ chatMessageOrigin: this.chatMessage.id });
  }
}

/**
 * Custom element that adds a filigree border that can be colored.
 */
class FiligreeBoxElement extends AdoptedStyleSheetMixin(HTMLElement) {
  constructor() {
    super();
    this.#shadowRoot = this.attachShadow({ mode: "closed" });
    this._adoptStyleSheet(this._getStyleSheet());
    const backdrop = document.createElement("div");
    backdrop.classList.add("backdrop");
    this.#shadowRoot.appendChild(backdrop);
    this.#buildSVG("corner", "top", "left");
    this.#buildSVG("corner", "top", "right");
    this.#buildSVG("corner", "bottom", "left");
    this.#buildSVG("corner", "bottom", "right");
    this.#buildSVG("block", "top");
    this.#buildSVG("block", "bottom");
    this.#buildSVG("inline", "left");
    this.#buildSVG("inline", "right");
    const slot = document.createElement("slot");
    this.#shadowRoot.appendChild(slot);
  }

  /** @inheritDoc */
  static CSS = `
    :host {
      position: relative;
      isolation: isolate;
      min-height: 56px;
      filter: var(--filigree-drop-shadow, drop-shadow(0 0 12px var(--dnd5e-shadow-15)));
    }
    .backdrop {
      --chamfer: 12px;
      position: absolute;
      inset: 0;
      background: var(--filigree-background-color, var(--dnd5e-color-card));
      z-index: -2;
      clip-path: polygon(
        var(--chamfer) 0,
        calc(100% - var(--chamfer)) 0,
        100% var(--chamfer),
        100% calc(100% - var(--chamfer)),
        calc(100% - var(--chamfer)) 100%,
        var(--chamfer) 100%,
        0 calc(100% - var(--chamfer)),
        0 var(--chamfer)
      );
    }
    .filigree {
      position: absolute;
      fill: var(--filigree-border-color, var(--dnd5e-color-gold));
      z-index: -1;

      &.top, &.bottom { height: 30px; }
      &.top { top: 0; }
      &.bottom { bottom: 0; scale: 1 -1; }

      &.left, &.right { width: 25px; }
      &.left { left: 0; }
      &.right { right: 0; scale: -1 1; }

      &.bottom.right { scale: -1 -1; }
    }
    .filigree.block {
      inline-size: calc(100% - 50px);
      inset-inline: 25px;
    }
    .filigree.inline {
      block-size: calc(100% - 60px);
      inset-block: 30px;
    }
  `;

  /**
   * Path definitions for the various box corners and edges.
   * @type {object}
   */
  static svgPaths = Object.freeze({
    corner: "M 3 21.7 C 5.383 14.227 9.646 7.066 18.1 3.2 L 12.2 3.2 L 3 12.8 Z M 6.9 15.7 C 5.088 19.235 3.776 23.004 3 26.9 L 2.999 30 L 0 30 L 0 11.5 L 11 0 L 25 0 L 25 3.1 L 22.4 3.1 C 16.737 4.586 11.822 8.112 8.6 13 L 8.6 30 L 6.9 30 Z",
    block: "M 0 0 L 10 0 L 10 3.1 L 0 3.1 L 0 0 Z",
    inline: "M 0 10 L 0 0 L 2.99 0 L 2.989 10 L 0 10 Z M 6.9 10 L 6.9 0 L 8.6 0 L 8.6 10 L 6.9 10 Z"
  });

  /**
   * Shadow root that contains the box shapes.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */

  /** @inheritDoc */
  _adoptStyleSheet(sheet) {
    this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /**
   * Build an SVG element.
   * @param {string} path          SVG path to use.
   * @param {...string} positions  Additional position CSS classes to add.
   */
  #buildSVG(path, ...positions) {
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.classList.add("filigree", path, ...positions);
    svg.innerHTML = `<path d="${FiligreeBoxElement.svgPaths[path]}" />`;
    svg.setAttribute("viewBox", `0 0 ${path === "block" ? 10 : 25} ${path === "inline" ? 10 : 30}`);
    svg.setAttribute("preserveAspectRatio", "none");
    this.#shadowRoot.appendChild(svg);
  }
}

/**
 * Input element that represents a three-state filter (include, exclude, or ignore). This is used for filters in
 * the compendium browser and in the inventory element. Returns a number with `1` indicating this filter should be
 * positively applied (show items that match the filter), `-1` indicating it should be negatively applied (hide
 * items that match the filter), and `0` indicating this filter should be ignored.
 */
class FilterStateElement extends foundry.applications.elements.AbstractFormInputElement {
  constructor(...args) {
    super(...args);
    this._value = this.getAttribute("value") ?? 0;
  }

  /* -------------------------------------------- */

  /** @override */
  static tagName = "filter-state";

  /* -------------------------------------------- */

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  _controller;

  /* -------------------------------------------- */

  /**
   * Internal indicator used to render the input.
   * @type {HTMLElement}
   */
  #indicator;

  /* -------------------------------------------- */
  /*  Element Properties                          */
  /* -------------------------------------------- */

  /** @override */
  _getValue() {
    return Number(this._value);
  }

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /** @override */
  connectedCallback() {
    this._controller = new AbortController();
    const elements = this._buildElements();
    this.replaceChildren(...elements);
    this._refresh();
    this._toggleDisabled(!this.editable);
    this._activateListeners();
    if ( !this.hasAttribute("tabindex") ) this.tabIndex = 0;
  }

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this._controller.abort();
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    this.#indicator = document.createElement("div");
    this.#indicator.classList.add("indicator");
    return [this.#indicator];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    this.#indicator.dataset.value = this.value ?? 0;
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const { signal } = this._controller;
    this.addEventListener("click", this._onClick.bind(this), { signal });
    this.addEventListener("contextmenu", this._onClick.bind(this), { signal });
    this.addEventListener("keydown", event => event.key === " " ? this.#handleValueChange() : null, { signal });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClick(event) {
    this.#handleValueChange(event.button === 2);
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the value based on a click or keyboard trigger.
   * @param {boolean} [backwards=false]  Should the value be decreased rather than increased?
   */
  #handleValueChange(backwards=false) {
    if ( this.disabled ) return;
    let newValue = (this.value ?? 0) + (backwards ? -1 : 1);
    if ( newValue > 1 ) newValue = -1;
    else if ( newValue < -1 ) newValue = 1;
    this.value = newValue;
  }
}

/**
 * Custom element for displaying SVG icons that are cached and can be styled.
 */
class IconElement extends AdoptedStyleSheetMixin(HTMLElement) {
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#internals.role = "img";
    this.#shadowRoot = this.attachShadow({ mode: "closed" });
  }

  /** @inheritDoc */
  static CSS = `
    :host {
      display: contents;
    }
    svg {
      fill: var(--icon-fill, #000);
      width: var(--icon-width, var(--icon-size, 1em));
      height: var(--icon-height, var(--icon-size, 1em));
    }
  `;

  /**
   * Cached SVG files by SRC.
   * @type {Map<string, SVGElement|Promise<SVGElement>>}
   */
  static #svgCache = new Map();

  /**
   * The custom element's form and accessibility internals.
   * @type {ElementInternals}
   */
  #internals;

  /**
   * Shadow root that contains the icon.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */

  /**
   * Path to the SVG source file.
   * @type {string}
   */
  get src() {
    return this.getAttribute("src");
  }

  set src(src) {
    this.setAttribute("src", src);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _adoptStyleSheet(sheet) {
    this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  connectedCallback() {
    this._adoptStyleSheet(this._getStyleSheet());
    const insertElement = element => {
      if ( !element ) return;
      const clone = element.cloneNode(true);
      this.#shadowRoot.replaceChildren(clone);
    };

    // Insert element immediately if already available, otherwise wait for fetch
    const element = this.constructor.fetch(this.src);
    if ( element instanceof Promise ) element.then(insertElement);
    else insertElement(element);
  }

  /* -------------------------------------------- */

  /**
   * Fetch an SVG element from a source.
   * @param {string} src                        Path of the SVG file to retrieve.
   * @returns {SVGElement|Promise<SVGElement>}  Promise if the element is not cached, otherwise the element directly.
   */
  static fetch(src) {
    if ( !this.#svgCache.has(src) ) this.#svgCache.set(src, fetch(src)
      .then(b => b.text())
      .then(t => {
        const temp = document.createElement("div");
        temp.innerHTML = t;
        const svg = temp.querySelector("svg");
        this.#svgCache.set(src, svg);
        return svg;
      }));
    return this.#svgCache.get(src);
  }
}

/**
 * Custom element that handles displaying actor & container inventories.
 */
class InventoryElement extends HTMLElement {
  connectedCallback() {
    this.#app = ui.windows[this.closest(".app")?.dataset.appid];

    this._initializeFilterLists();

    if ( !this.canUse ) {
      for ( const element of this.querySelectorAll('[data-action="use"]') ) {
        element.dataset.action = null;
        element.closest(".rollable")?.classList.remove("rollable");
      }
    }

    for ( const input of this.querySelectorAll('input[type="number"]') ) {
      input.addEventListener("change", this._onChangeInput.bind(this));
    }

    for ( const input of this.querySelectorAll('input[inputmode="numeric"]') ) {
      input.addEventListener("change", this._onChangeInputDelta.bind(this));
    }

    for ( const button of this.querySelectorAll(".adjustment-button") ) {
      button.addEventListener("click", this._onAdjustInput.bind(this));
    }

    for ( const control of this.querySelectorAll(".item-action[data-action]") ) {
      control.addEventListener("click", event => {
        this._onAction(event.currentTarget, event.currentTarget.dataset.action, { event });
      });
    }

    for ( const control of this.querySelectorAll("[data-context-menu]") ) {
      control.addEventListener("click", event => {
        event.preventDefault();
        event.stopPropagation();
        const { clientX, clientY } = event;
        event.currentTarget.closest("[data-item-id]").dispatchEvent(new PointerEvent("contextmenu", {
          view: window, bubbles: true, cancelable: true, clientX, clientY
        }));
      });
    }

    this.querySelectorAll("input").forEach(e => e.addEventListener("focus", () => e.select()));

    // Bind activity menu to child to work around lack of stopImmediatePropagation in ContextMenu#bind
    new ContextMenu5e(this.querySelector(".items-list"), ".activity-row[data-activity-id]", [], {
      onOpen: this._onOpenContextMenu.bind(this), jQuery: true
    });

    // Item context menus
    const MenuCls = this.hasAttribute("v2") ? ContextMenu5e : ContextMenu;
    new MenuCls(this, "[data-item-id]", [], { onOpen: this._onOpenContextMenu.bind(this), jQuery: true });
  }

  /* -------------------------------------------- */

  /**
   * Prepare filter lists and attach their listeners.
   * @protected
   */
  _initializeFilterLists() {
    const filterLists = this.querySelectorAll(".filter-list");
    if ( !this._app._filters || !filterLists.length ) return;

    // Activate the set of filters which are currently applied
    for ( const list of filterLists ) {
      const state = this._app._filters[list.dataset.filter];
      if ( !state ) continue;
      const set = state.properties;
      const filters = list.querySelectorAll(".filter-item");
      for ( const filter of filters ) {
        if ( set.has(filter.dataset.filter) ) filter.classList.add("active");
        filter.addEventListener("click", () => {
          const f = filter.dataset.filter;
          if ( set.has(f) ) set.delete(f);
          else set.add(f);
          filter.classList.toggle("active", set.has(f));
          this._applyFilters(state);
        });
      }
      this._applyFilters(state);
    }
  }

  /* -------------------------------------------- */

  /**
   * TODO: Remove filtering code from dnd5e-inventory when all sheets use item-list-controls.
   * Apply the current set of filters to the inventory list.
   * @param {FilterState5e} state  The filter state to apply.
   * @protected
   */
  _applyFilters(state) {
    let items = this._app._filterItems?.(this._app.object.items, state.properties);
    if ( !items ) return;
    const elementMap = {};
    this.querySelectorAll(".inventory-list .item-list .item").forEach(el => {
      elementMap[el.dataset.itemId] = el;
      el.hidden = true;
    });
    for ( const item of items ) {
      const el = elementMap[item.id];
      if ( el ) el.hidden = false;
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Reference to the application that contains this component.
   * @type {Application}
   */
  #app;

  /**
   * Reference to the application that contains this component.
   * @type {Application}
   * @protected
   */
  get _app() { return this.#app; }

  /* -------------------------------------------- */

  /**
   * Can items be used directly from the inventory?
   * @type {boolean}
   */
  get canUse() {
    return !(!this.actor || !this.actor.isOwner || this.actor.pack);
  }

  /* -------------------------------------------- */

  /**
   * Containing actor for this inventory, either the document or its parent if document is an item.
   * @type {Actor5e|null}
   */
  get actor() {
    if ( this.document instanceof Actor ) return this.document;
    return this.document.actor ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Document whose inventory is represented.
   * @type {Actor5e|Item5e}
   */
  get document() {
    return this._app.document;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve an item with the specified ID.
   * @param {string} id
   * @returns {Item5e|Promise<Item5e>}
   */
  getItem(id) {
    if ( this.document.type === "container" ) return this.document.system.getContainedItem(id);
    return this.document.items.get(id);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Prepare an array of context menu options which are available for inventory items.
   * @param {Item5e} item           The Item for which the context menu is activated.
   * @param {HTMLElement} [element] The element the context menu was spawned from.
   * @returns {ContextMenuEntry[]}  An array of context menu options offered for the Item.
   * @protected
   */
  _getContextOptions(item, element) {
    const compendiumLocked = item[game.release.generation < 13 ? "compendium" : "collection"]?.locked;
    // TODO: Move away from using jQuery in callbacks once V12 support is dropped

    // Standard Options
    const options = [
      {
        name: "DND5E.ItemView",
        icon: '<i class="fas fa-eye"></i>',
        callback: li => this._onAction(li[0], "view")
      },
      {
        name: "DND5E.ContextMenuActionEdit",
        icon: "<i class='fas fa-edit fa-fw'></i>",
        condition: () => item.isOwner && !compendiumLocked,
        callback: li => this._onAction(li[0], "edit")
      },
      {
        name: "DND5E.ContextMenuActionDuplicate",
        icon: "<i class='fas fa-copy fa-fw'></i>",
        condition: () => item.canDuplicate && item.isOwner && !compendiumLocked,
        callback: li => this._onAction(li[0], "duplicate")
      },
      {
        name: "DND5E.ContextMenuActionDelete",
        icon: "<i class='fas fa-trash fa-fw'></i>",
        condition: () => item.canDelete && item.isOwner && !compendiumLocked,
        callback: li => this._onAction(li[0], "delete")
      },
      {
        name: "DND5E.DisplayCard",
        icon: '<i class="fas fa-message"></i>',
        callback: () => item.displayCard()
      },
      {
        name: "DND5E.Scroll.CreateScroll",
        icon: '<i class="fa-solid fa-scroll"></i>',
        callback: async li => {
          const scroll = await Item5e.createScrollFromSpell(item);
          if ( scroll ) Item5e.create(scroll, { parent: this.actor });
        },
        condition: li => (item.type === "spell") && !item.getFlag("dnd5e", "cachedFor") && this.actor?.isOwner
          && !this.actor?.[game.release.generation < 13 ? "compendium" : "collection"]?.locked,
        group: "action"
      },
      {
        name: "DND5E.ConcentrationBreak",
        icon: '<dnd5e-icon src="systems/dnd5e/icons/svg/break-concentration.svg"></dnd5e-icon>',
        condition: () => this.actor?.concentration?.items.has(item),
        callback: () => this.actor?.endConcentration(item),
        group: "state"
      }
    ];

    if ( !this.actor || (this.actor.type === "group") ) return options;

    // Toggle Attunement State
    if ( item.system.attunement ) {
      options.push({
        name: item.system.attuned ? "DND5E.ContextMenuActionUnattune" : "DND5E.ContextMenuActionAttune",
        icon: "<i class='fas fa-sun fa-fw'></i>",
        condition: () => item.isOwner && !compendiumLocked,
        callback: li => this._onAction(li[0], "attune"),
        group: "state"
      });
    }

    // Toggle Equipped State
    if ( "equipped" in item.system ) options.push({
      name: item.system.equipped ? "DND5E.ContextMenuActionUnequip" : "DND5E.ContextMenuActionEquip",
      icon: "<i class='fas fa-shield-alt fa-fw'></i>",
      condition: () => item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li[0], "equip"),
      group: "state"
    });

    // Toggle Charged State
    if ( item.hasRecharge ) options.push({
      name: item.isOnCooldown ? "DND5E.ContextMenuActionCharge" : "DND5E.ContextMenuActionExpendCharge",
      icon: '<i class="fa-solid fa-bolt"></i>',
      condition: () => item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li[0], "toggleCharge"),
      group: "state"
    });

    // Toggle Prepared State
    else if ( ("preparation" in item.system) && (item.system.preparation?.mode === "prepared")
      && !item.getFlag("dnd5e", "cachedFor") ) options.push({
      name: item.system?.preparation?.prepared ? "DND5E.ContextMenuActionUnprepare" : "DND5E.ContextMenuActionPrepare",
      icon: "<i class='fas fa-sun fa-fw'></i>",
      condition: () => item.isOwner && !compendiumLocked,
      callback: li => this._onAction(li[0], "prepare"),
      group: "state"
    });

    // Identification
    if ( "identified" in item.system ) options.push({
      name: "DND5E.Identify",
      icon: '<i class="fas fa-magnifying-glass"></i>',
      condition: () => item.isOwner && !compendiumLocked && !item.system.identified,
      callback: () => item.update({ "system.identified": true }),
      group: "state"
    });

    // Toggle Favorite State
    if ( "favorites" in this.actor.system ) {
      const uuid = item.getRelativeUUID(this.actor);
      const isFavorited = this.actor.system.hasFavorite(uuid);
      options.push({
        name: isFavorited ? "DND5E.FavoriteRemove" : "DND5E.Favorite",
        icon: '<i class="fas fa-bookmark fa-fw"></i>',
        condition: () => item.isOwner && !compendiumLocked,
        callback: li => this._onAction(li[0], isFavorited ? "unfavorite" : "favorite"),
        group: "state"
      });
    }

    // Toggle Collapsed State
    if ( this._app.canExpand?.(item) ) {
      const expanded = this._app._expanded.has(item.id);
      options.push({
        name: expanded ? "Collapse" : "Expand",
        icon: `<i class="fas fa-${expanded ? "compress" : "expand"}"></i>`,
        callback: () => element.closest("[data-item-id]")?.querySelector("[data-toggle-description]")?.click(),
        group: "collapsible"
      });
    }

    return options;
  }

  /* -------------------------------------------- */

  /**
   * Handle changing the quantity or charges fields.
   * @param {Event} event  Triggering change event.
   * @returns {Promise}
   * @protected
   */
  async _onChangeInput(event) {
    const itemId = event.target.closest("[data-item-id]")?.dataset.itemId;
    if ( !itemId ) return;

    event.stopImmediatePropagation();
    const item = await this.getItem(itemId);
    const min = event.target.min !== "" ? Number(event.target.min) : -Infinity;
    const max = event.target.max !== "" ? Number(event.target.max) : Infinity;
    const value = Math.clamp(event.target.valueAsNumber, min, max);
    if ( !item || Number.isNaN(value) ) return;

    event.target.value = value;
    item.update({[event.target.dataset.name]: value});
  }

  /* -------------------------------------------- */

  /**
   * Handle input changes to numeric form fields, allowing them to accept delta-typed inputs.
   * @param {Event} event  Triggering event.
   * @protected
   */
  async _onChangeInputDelta(event) {
    // If this is already handled by the parent sheet, skip.
    if ( this.#app?._onChangeInputDelta ) return;
    const input = event.target;
    const { itemId } = input.closest("[data-item-id]")?.dataset ?? {};
    const { activityId } = input.closest("[data-activity-id]")?.dataset ?? {};
    const item = await this.getItem(itemId);
    if ( !item ) return;
    const activity = item.system.activities?.get(activityId);
    const result = parseInputDelta(input, activity ?? item);
    if ( result !== undefined ) {
      // Special case handling for Item uses.
      if ( input.dataset.name === "system.uses.value" ) {
        item.update({ "system.uses.spent": item.system.uses.max - result });
      } else if ( activity && (input.dataset.name === "uses.value") ) {
        item.updateActivity(activityId, { "uses.spent": activity.uses.max - result });
      }
      else item.update({ [input.dataset.name]: result });
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle incrementing or decrementing a numeric input.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onAdjustInput(event) {
    const button = event.currentTarget;
    const { action } = button.dataset;
    const input = button.parentElement.querySelector("input");
    const min = input.min ? Number(input.min) : -Infinity;
    const max = input.max ? Number(input.max) : Infinity;
    let value = Number(input.value);
    if ( isNaN(value) ) return;
    value += action === "increase" ? 1 : -1;
    input.value = Math.clamp(value, min, max);
    input.dispatchEvent(new Event("change"));
  }

  /* -------------------------------------------- */

  /**
   * Handle item actions.
   * @param {Element} target                Button or context menu entry that triggered this action.
   * @param {string} action                 Action being triggered.
   * @param {object} [options]
   * @param {PointerEvent} [options.event]  The original triggering event.
   * @returns {Promise}
   * @protected
   */
  async _onAction(target, action, { event }={}) {
    const inventoryEvent = new CustomEvent("inventory", {
      bubbles: true,
      cancelable: true,
      detail: action
    });
    if ( target.dispatchEvent(inventoryEvent) === false ) return;

    const { itemId } = target.closest("[data-item-id]")?.dataset ?? {};
    const { activityId } = target.closest("[data-activity-id]")?.dataset ?? {};
    const item = await this.getItem(itemId);
    if ( !["create", "currency"].includes(action) && !item ) return;
    const activity = item?.system.activities?.get(activityId);

    switch ( action ) {
      case "activity-recharge":
        return activity?.uses?.rollRecharge({ apply: true, event });
      case "activity-use":
        return activity?.use({ event });
      case "attune":
        return item.update({"system.attuned": !item.system.attuned});
      case "create":
        if ( this.document.type === "container" ) return;
        return this._onCreate(target);
      case "crew":
        return item.update({"system.crewed": !item.system.crewed});
      case "currency":
        return new CurrencyManager({ document: this.document }).render({ force: true });
      case "delete":
        return item.deleteDialog();
      case "duplicate":
        return item.clone({name: game.i18n.format("DOCUMENT.CopyOf", {name: item.name})}, {save: true});
      case "edit":
        return item.sheet.render(true, { mode: ItemSheet5e2.MODES.EDIT });
      case "view":
        return item.sheet.render(true, { mode: ItemSheet5e2.MODES.PLAY });
      case "equip":
        return item.update({"system.equipped": !item.system.equipped});
      case "expand":
        return this._onExpand(target, item);
      case "favorite":
        return this.actor.system.addFavorite({type: "item", id: item.getRelativeUUID(this.actor)});
      case "prepare":
        return item.update({"system.preparation.prepared": !item.system.preparation?.prepared});
      case "recharge":
        return item.system.uses?.rollRecharge({ apply: true, event });
      case "toggleCharge":
        return item.update({ "system.uses.spent": 1 - item.system.uses.spent });
      case "unfavorite":
        return this.actor.system.removeFavorite(item.getRelativeUUID(this.actor));
      case "use":
        return item.use({ legacy: false, event });
    }
  }

  /* -------------------------------------------- */

  /**
   * Create a new item.
   * @param {HTMLElement} target  Button or context menu entry that triggered this action.
   * @returns {Promise<Item5e>}
   */
  async _onCreate(target) {
    const { type, ...dataset } = (target.closest(".spellbook-header") ?? target).dataset;
    delete dataset.action;
    delete dataset.tooltip;

    // Check to make sure the newly created class doesn't take player over level cap
    if ( type === "class" && (this.actor.system.details.level + 1 > CONFIG.DND5E.maxLevel) ) {
      const err = game.i18n.format("DND5E.MaxCharacterLevelExceededWarn", {max: CONFIG.DND5E.maxLevel});
      ui.notifications.error(err);
      return null;
    }

    const itemData = {
      name: game.i18n.format("DND5E.ItemNew", {type: game.i18n.localize(CONFIG.Item.typeLabels[type])}),
      type,
      system: foundry.utils.expandObject({ ...dataset })
    };
    delete itemData.system.type;
    return this.actor.createEmbeddedDocuments("Item", [itemData]);
  }

  /* -------------------------------------------- */

  /**
   * Expand or collapse an item's summary.
   * @param {HTMLElement} target  Button or context menu entry that triggered this action.
   * @param {Item5e} item         Item to being expanded or collapsed.
   */
  async _onExpand(target, item) {
    const li = target.closest("[data-item-id]");
    if ( this._app._expanded.has(item.id) ) {
      const summary = $(li.querySelector(".item-summary"));
      summary.slideUp(200, () => summary.remove());
      this._app._expanded.delete(item.id);
    } else {
      const chatData = await item.getChatData({secrets: this.document.isOwner});
      const summary = $(await renderTemplate("systems/dnd5e/templates/items/parts/item-summary.hbs", chatData));
      $(li).append(summary.hide());
      summary.slideDown(200);
      this._app._expanded.add(item.id);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle opening the context menu.
   * @param {HTMLElement} element  The element the context menu was triggered on.
   * @protected
   */
  _onOpenContextMenu(element) {
    const item = this.getItem(element.closest("[data-item-id]")?.dataset.itemId);
    // Parts of ContextMenu doesn't play well with promises, so don't show menus for containers in packs
    if ( !item || (item instanceof Promise) ) return;
    if ( element.closest("[data-activity-id]") ) {
      dnd5e.documents.activity.UtilityActivity.onContextMenu(item, element);
    } else {
      ui.context.menuItems = this._getContextOptions(item, element);
      Hooks.call("dnd5e.getItemContextOptions", item, ui.context.menuItems);
    }
  }
}

/**
 * A custom element that encapsulates functionality for sorting, filtering, searching, and grouping lists of items.
 */
class ItemListControlsElement extends HTMLElement {
  /** @override */
  connectedCallback() {
    this.#app = ui.windows[this.closest(".app")?.dataset.appid];
    this.#list = this.#app.form.querySelector(`[data-item-list="${this.getAttribute("for")}"]`);
    this.#state = this.#app._filters[this.getAttribute("for")];
    this.#tab = this.closest(".tab")?.dataset.tab;
    this.#buildHTML();

    const debouncedFilter = foundry.utils.debounce(this._onFilterName.bind(this), this.constructor.FILTER_DEBOUNCE_MS);
    this._inputElement.addEventListener("input", debouncedFilter);
    this._controls.clear.addEventListener("click", this._onClearFilters.bind(this));
    this._controls.sort?.addEventListener("click", this._onToggleMode.bind(this));
    this._controls.group?.addEventListener("click", this._onToggleMode.bind(this));

    this._initFilters();
    this._initGrouping();
    this._initSorting();
    this._applyGrouping();
  }

  /* -------------------------------------------- */
  /*  Properties & Getters                        */
  /* -------------------------------------------- */

  /**
   * @typedef {object} SortModeConfiguration5e
   * @property {string} icon
   * @property {string} label
   * @property {ItemListComparator5e} comparator
   */

  /**
   * @callback ItemListComparator5e
   * @param {object} a
   * @param {object} b
   * @returns {number}
   */

  /**
   * Sort mode configuration.
   * @type {Record<string, SortModeConfiguration5e>}
   */
  static SORT_MODES = {
    a: {
      icon: "fa-arrow-down-a-z",
      label: "SIDEBAR.SortModeAlpha",
      comparator: (a, b) => a.name.localeCompare(b.name, game.i18n.lang)
    },
    p: {
      icon: "fa-arrow-down-1-9",
      label: "SIDEBAR.SortModePriority",
      comparator: (a, b) => a.linkedName?.localeCompare(b.linkedName, game.i18n.lang)
        || (a.level - b.level)
        || (a.preparationMode - b.preparationMode)
        || (a.prepared - b.prepared)
        || a.name.localeCompare(b.name, game.i18n.lang)
    },
    m: {
      icon: "fa-arrow-down-short-wide",
      label: "SIDEBAR.SortModeManual",
      comparator: (a, b) => a.sort - b.sort
    }
  };

  /**
   * The amount of time to wait after a user's keypress before the name search filter is applied, in milliseconds.
   * @type {number}
   */
  static FILTER_DEBOUNCE_MS = 200;

  #app;

  /**
   * The Application instance that houses this item control.
   * @type {FormApplication}
   */
  get app() {
    return this.#app;
  }

  #list;

  /**
   * The list element that this element manages.
   * @type {HTMLElement}
   */
  get list() {
    return this.#list;
  }

  #state;

  /**
   * The current filter state.
   * @type {FilterState5e}
   */
  get state() {
    return this.#state;
  }

  #tab;

  /**
   * The tab this element is part of.
   * @type {string}
   */
  get tab() {
    return this.#tab;
  }

  /**
   * The search input.
   * @type {HTMLInputElement}
   * @protected
   */
  _inputElement;

  /**
   * The available filtering choices.
   * @type {NodeListOf<HTMLButtonElement>}
   * @protected
   */
  _filterItems;

  /**
   * The individual filtering controls.
   * @type {Record<string, HTMLButtonElement>}
   * @protected
   */
  _controls;

  /**
   * The user's preferences for this tab.
   * @type {TabPreferences5e}
   */
  get prefs() {
    return game.user.getFlag("dnd5e", `sheetPrefs.${this.app.object.type}.tabs.${this.tab}`);
  }

  /**
   * Whether to keep empty sections visible.
   * @type {boolean}
   */
  get keepEmpty() {
    return this.hasAttribute("keep-empty");
  }

  /**
   * Get the current sort mode.
   * @type {"a"|"p"|"m"}
   */
  get sortMode() {
    const sortMode = this.getAttribute("sort");
    if ( !sortMode ) return "m";
    if ( sortMode === "toggle" ) return this.prefs?.sort === "a" ? "a" : "m";
    if ( sortMode === "multi" ) {
      const modes = Array.from(this.getAttribute("sort-modes") || "");
      if ( !modes.length ) return "m";
      return modes.includes(this.prefs?.sort) ? this.prefs.sort : modes[0];
    }
    return sortMode;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Construct the element's internal markup.
   */
  #buildHTML() {
    const options = this.querySelectorAll("option");
    const search = document.createElement("search");
    search.setAttribute("aria-label", this.getAttribute("label"));
    search.innerHTML = `
      <input type="text" class="interface-only" placeholder="${this.getAttribute("label")}">
      <ul class="unlist controls">
        <li>
          <button type="button" class="unbutton filter-control interface-only" data-action="clear"
                  data-tooltip="DND5E.FilterClear" aria-label="${game.i18n.localize("DND5E.FilterClear")}">
            <i class="fas fa-xmark"></i>
          </button>
        </li>
      </ul>
    `;

    const controls = search.querySelector(".controls");

    // Filtering
    if ( options.length ) {
      const item = document.createElement("li");
      item.classList.add("dropdown");
      item.innerHTML = `
        <button type="button" class="unbutton filter-control filter interface-only" data-action="filter"
                aria-label="${game.i18n.localize("DND5E.Filter")}">
          <i class="fas fa-filter"></i>
        </button>
        <ul class="filter-list unlist"></ul>
      `;
      controls.appendChild(item);

      const list = item.querySelector(".filter-list");
      options.forEach(option => {
        const item = document.createElement("li");
        item.innerHTML = `
          <button type="button" class="filter-item interface-only" data-filter="${option.value}">
            ${option.innerText}
          </button>
        `;
        list.appendChild(item);
      });
    }

    // Sorting
    const sortMode = this.getAttribute("sort");
    if ( ["toggle", "multi"].includes(sortMode) ) {
      const item = document.createElement("li");
      item.innerHTML = `
        <button type="button" class="unbutton filter-control active interface-only" data-action="sort"
                data-tooltip="SIDEBAR.SortModeManual" aria-label="${game.i18n.localize("SIDEBAR.SortModeManual")}">
          <i class="fas fa-arrow-down-short-wide"></i>
        </button>
      `;
      controls.appendChild(item);
    }

    // Grouping
    if ( this.hasAttribute("group") ) {
      const groupLabel = this.getAttribute("group-label");
      const item = document.createElement("li");
      item.innerHTML = `
        <button type="button" class="unbutton filter-control active interface-only" data-action="group"
                data-tooltip="${groupLabel}" aria-label="${groupLabel}">
          <i class="fas fa-layer-group"></i>
        </button>
      `;
      controls.appendChild(item);
    }

    this._inputElement = search.querySelector(":scope > input");
    this._filterItems = search.querySelectorAll(".filter-item");
    this._controls = {};
    search.querySelectorAll(".filter-control").forEach(el => this._controls[el.dataset.action] = el);
    this.replaceChildren(search);
  }

  /**
   * Initialize the elements based on the filter state.
   * @protected
   */
  _initFilters() {
    const { properties, name } = this.state;
    this._inputElement.value = name;
    for ( const item of this._filterItems ) {
      item.classList.toggle("active", properties.has(item.dataset.filter));
      item.addEventListener("click", this._onToggleFilterItem.bind(this));
    }
  }

  /* -------------------------------------------- */

  /**
   * Initialize the elements based on the grouping preferences.
   * @protected
   */
  _initGrouping() {
    this._controls.group?.classList.toggle("active", this.prefs?.group !== false);
  }

  /* -------------------------------------------- */

  /**
   * Initialize the element sorting.
   * @protected
   */
  _initSorting() {
    if ( (this.getAttribute("sort") !== "toggle") && (this.getAttribute("sort") !== "multi") ) return;
    const { icon, label } = this.constructor.SORT_MODES[this.sortMode];
    this._controls.sort.querySelector("i").className = `fas ${icon}`;
    this._controls.sort.dataset.tooltip = label;
    this._controls.sort.setAttribute("aria-label", game.i18n.localize(label));
  }

  /* -------------------------------------------- */

  /**
   * Apply the filters to the managed list.
   * @protected
   */
  _applyFilters() {
    const { name, properties } = this.state;
    this._controls.clear.classList.toggle("active", properties.size || name);
    let entries = this.app._filterChildren?.(this.getAttribute("collection") ?? "items", properties);
    if ( !entries ) return;
    if ( name ) entries = entries.filter(item => item.name.toLocaleLowerCase(game.i18n.lang).includes(name));
    const elementMap = {};
    if ( !this.keepEmpty ) this.list.querySelectorAll(".items-section").forEach(el => el.hidden = true);
    this.list.querySelectorAll(".item-list .item").forEach(el => {
      const uniqueID = el.dataset.parentId ? `${el.dataset.parentId}.${el.dataset.entryId}` : el.dataset.entryId;
      elementMap[uniqueID] = el;
      el.hidden = true;
    });
    for ( const entry of entries ) {
      const el = elementMap[`${entry.parent?.id}.${entry.id}`] ?? elementMap[entry.id];
      if ( el ) el.hidden = false;
    }
    this.list.querySelectorAll(".items-section:has(.item-list .item:not([hidden]))").forEach(el => el.hidden = false);
  }

  /* -------------------------------------------- */

  /**
   * Group the managed items.
   * @protected
   */
  _applyGrouping() {
    if ( this._controls.group ) {
      const group = this.prefs?.group !== false;
      const sections = {};
      for ( const section of this.list.querySelectorAll(".items-section") ) {
        sections[section.dataset.type] = section.querySelector(".item-list");
      }
      for ( const item of this.list.querySelectorAll(".item") ) {
        const { grouped, ungrouped } = item.dataset;
        const section = sections[group ? grouped : ungrouped];
        section?.appendChild(item);
      }
    }
    this._applyFilters();
    this._applySorting();
  }

  /* -------------------------------------------- */

  /**
   * Sort the managed list.
   * @protected
   */
  _applySorting() {
    for ( const section of this.list.querySelectorAll(".items-section .item-list") ) {
      const items = [];
      section.querySelectorAll(".item").forEach(element => {
        const {
          itemName, itemSort, itemLevel, itemPreparationMode, itemPreparationPrepared, linkedName
        } = element.dataset;
        items.push({
          element, linkedName,
          name: itemName,
          sort: Number(itemSort),
          level: Number(itemLevel),
          preparationMode: itemPreparationMode === "always" ? 0 : 1,
          prepared: itemPreparationPrepared === "true" ? 0 : 1
        });
      });
      items.sort(this.constructor.SORT_MODES[this.sortMode].comparator);
      section.replaceChildren(...items.map(({ element }) => element));
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling a filter item.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onToggleFilterItem(event) {
    const target = event.currentTarget;
    const { properties } = this.state;
    const filter = target.dataset.filter;
    if ( properties.has(filter) ) properties.delete(filter);
    else properties.add(filter);
    target.classList.toggle("active", properties.has(filter));
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle toggling the sorting or grouping modes.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  async _onToggleMode(event) {
    const { action } = event.currentTarget.dataset;
    const flag = `sheetPrefs.${this.app.object.type}.tabs.${this.tab}.${action}`;
    const current = game.user.getFlag("dnd5e", flag);
    let value;
    if ( action === "group" ) value = current === false;
    else if ( action === "sort" ) {
      const values = Array.from(this.getAttribute("sort-modes") ?? "am");
      const index = values.indexOf(current);
      value = values[index + 1] ?? values[0];
    }
    await game.user.setFlag("dnd5e", flag, value);
    if ( action === "group" ) {
      this._initGrouping();
      this._applyGrouping();
    } else if ( action === "sort" ) {
      this._initSorting();
      this._applySorting();
    }
    game.tooltip.deactivate();
  }

  /* -------------------------------------------- */

  /**
   * Handle the user filtering by name.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onFilterName(event) {
    this.state.name = event.target.value.toLocaleLowerCase(game.i18n.lang);
    this._applyFilters();
  }

  /* -------------------------------------------- */

  /**
   * Handle clearing the filters.
   * @protected
   */
  _onClearFilters() {
    this.state.name = this._inputElement.value = "";
    this.state.properties.clear();
    this._filterItems.forEach(el => el.classList.remove("active"));
    this._applyFilters();
  }
}

/**
 * A custom HTML element that displays proficiency status and allows cycling through values.
 * @fires change
 */
class ProficiencyCycleElement extends AdoptedStyleSheetMixin(
  foundry.applications.elements.AbstractFormInputElement
) {
  /** @inheritDoc */
  constructor() {
    super();
    this._internals.role = "spinbutton";
    this.#shadowRoot = this.attachShadow({ mode: "open" });
    this._adoptStyleSheet(this._getStyleSheet());
    this._value = Number(this.getAttribute("value") ?? 0);
  }

  /** @inheritDoc */
  static CSS = `
    :host { display: inline-block; }
    div { --_fill: var(--proficiency-cycle-enabled-color, var(--dnd5e-color-blue)); }
    div:has(:disabled, :focus-visible) { --_fill: var(--proficiency-cycle-disabled-color, var(--dnd5e-color-gold)); }
    div:not(:has(:disabled)) { cursor: pointer; }

    div {
      position: relative;
      overflow: clip;
      width: 100%;
      aspect-ratio: 1;

      &::before {
        content: "";
        position: absolute;
        display: block;
        inset: 3px;
        border: 1px solid var(--_fill);
        border-radius: 100%;
      }

      &:has([value="1"])::before { background: var(--_fill); }

      &:has([value="0.5"], [value="2"])::after {
        content: "";
        position: absolute;
        background: var(--_fill);
      }

      &:has([value="0.5"])::after {
        inset: 4px;
        width: 4px;
        aspect-ratio: 1 / 2;
        border-radius: 100% 0 0 100%;
      }

      &:has([value="2"]) {
        &::before {
          inset: 1px;
          border-width: 2px;
        }

        &::after {
          inset: 5px;
          border-radius: 100%;
        }
      }
    }

    input {
      position: absolute;
      inset-block-start: -100px;
      width: 1px;
      height: 1px;
      opacity: 0;
    }
  `;

  /**
   * Controller for removing listeners automatically.
   * @type {AbortController}
   */
  #controller;

  /**
   * Shadow root of the element.
   * @type {ShadowRoot}
   */
  #shadowRoot;

  /* -------------------------------------------- */

  /** @override */
  _toggleDisabled(value) {
    this.#shadowRoot.querySelector("input")?.toggleAttribute("disabled", value);
  }

  /* -------------------------------------------- */

  /**
   * Type of proficiency represented by this control (e.g. "ability" or "skill").
   * @type {"ability"|"skill"|"tool"}
   */
  get type() { return this.getAttribute("type") ?? "ability"; }

  set type(value) {
    if ( !["ability", "skill", "tool"].includes(value) ) throw new Error("Type must be 'ability', 'skill', or 'tool'.");
    this.setAttribute("type", value);
    this._internals.ariaValueMin = 0;
    this._internals.ariaValueMax = value === "ability" ? 1 : 2;
    this._internals.ariaValueStep = value === "ability" ? 1 : 0.5;
  }

  /* -------------------------------------------- */

  /**
   * Valid values for the current type.
   * @type {number[]}
   */
  get validValues() {
    return this.type === "ability" ? [0, 1] : [0, 1, .5, 2];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _setValue(value) {
    if ( !this.validValues.includes(value) ) throw new Error("Value must be a valid proficiency multiplier.");
    return super._setValue(value);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _adoptStyleSheet(sheet) {
    this.#shadowRoot.adoptedStyleSheets = [sheet];
  }

  /* -------------------------------------------- */

  /** @override */
  _buildElements() {
    const div = document.createElement("div");
    this.#shadowRoot.replaceChildren(div);

    const input = document.createElement("input");
    input.setAttribute("type", "number");
    if ( this.disabled ) input.setAttribute("disabled", "");
    div.appendChild(input);

    return [];
  }

  /* -------------------------------------------- */

  /** @override */
  _refresh() {
    const input = this.#shadowRoot.querySelector("input");
    input.setAttribute("value", this._value);
    this._internals.ariaValueNow = this._value;
    this._internals.ariaValueText = CONFIG.DND5E.proficiencyLevels[this._value];
    this._internals.setFormValue(this._value);
    this._primaryInput = this.#shadowRoot.querySelector("input");
  }

  /* -------------------------------------------- */

  /** @override */
  _activateListeners() {
    const { signal } = this.#controller = new AbortController();
    this.addEventListener("click", this.#onClick.bind(this), { signal });
    this.addEventListener("contextmenu", this.#onClick.bind(this), { signal });
    this.#shadowRoot.querySelector("div").addEventListener("contextmenu", e => e.preventDefault(), { signal });
    this.#shadowRoot.querySelector("input").addEventListener("change", this.#onChangeInput.bind(this), { signal });
  }

  /* -------------------------------------------- */

  /** @override */
  disconnectedCallback() {
    this.#controller.abort();
  }

  /* -------------------------------------------- */

  /**
   * Redirect focus requests into the inner input.
   * @param {object} options  Focus options forwarded to inner input.
   */
  focus(options) {
    this.#shadowRoot.querySelector("input")?.focus(options);
  }

  /* -------------------------------------------- */

  /**
   * Change the value by one step, looping around if the limits have been reached.
   * @param {boolean} [up=true]  Should the value step up or down?
   */
  step(up=true) {
    const levels = this.validValues;
    const idx = levels.indexOf(this.value);
    this.value = levels[(idx + (up ? 1 : levels.length - 1)) % levels.length];
    this.dispatchEvent(new Event("change"));
  }

  /* -------------------------------------------- */

  /**
   * Handle changes to the input value directly.
   * @param {Event} event  Triggering change event.
   */
  #onChangeInput(event) {
    this.step(event.target.valueAsNumber > this.value);
  }

  /* -------------------------------------------- */

  /**
   * Handle a click event for modifying the value.
   * @param {PointerEvent} event  Triggering click event.
   */
  #onClick(event) {
    event.preventDefault();
    if ( this.disabled ) return;
    this.step((event.type === "click") && (event.button !== 2));
  }
}

/**
 * A custom HTML element that represents a checkbox-like input that is displayed as a slide toggle.
 * @fires change
 */
class SlideToggleElement extends CheckboxElement {
  /** @inheritDoc */
  constructor() {
    super();
    this._internals.role = "switch";
  }

  /* -------------------------------------------- */

  /** @override */
  static tagName = "slide-toggle";

  /* -------------------------------------------- */

  /** @override */
  static useShadowRoot = false;

  /* -------------------------------------------- */
  /*  Element Lifecycle                           */
  /* -------------------------------------------- */

  /**
   * Activate the element when it is attached to the DOM.
   * @inheritDoc
   */
  connectedCallback() {
    this.replaceChildren(...this._buildElements());
    this._refresh();
    this._activateListeners();
  }

  /* -------------------------------------------- */

  /**
   * Create the constituent components of this element.
   * @returns {HTMLElement[]}
   * @protected
   */
  _buildElements() {
    const track = document.createElement("div");
    track.classList.add("slide-toggle-track");
    const thumb = document.createElement("div");
    thumb.classList.add("slide-toggle-thumb");
    track.append(thumb);
    return [track];
  }
}

window.customElements.define("copyable-text", CopyableTextElement);
window.customElements.define("damage-application", DamageApplicationElement);
window.customElements.define("dnd5e-checkbox", CheckboxElement);
window.customElements.define("dnd5e-effects", EffectsElement);
window.customElements.define("dnd5e-icon", IconElement);
window.customElements.define("dnd5e-inventory", InventoryElement);
window.customElements.define("effect-application", EffectApplicationElement);
window.customElements.define("enchantment-application", EnchantmentApplicationElement);
window.customElements.define("filigree-box", FiligreeBoxElement);
window.customElements.define("filter-state", FilterStateElement);
window.customElements.define("item-list-controls", ItemListControlsElement);
window.customElements.define("proficiency-cycle", ProficiencyCycleElement);
window.customElements.define("slide-toggle", SlideToggleElement);

var _module$k = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AdoptedStyleSheetMixin: AdoptedStyleSheetMixin,
  CheckboxElement: CheckboxElement,
  CopyableTextElement: CopyableTextElement,
  DamageApplicationElement: DamageApplicationElement,
  EffectApplicationElement: EffectApplicationElement,
  EffectsElement: EffectsElement,
  EnchantmentApplicationElement: EnchantmentApplicationElement,
  FiligreeBoxElement: FiligreeBoxElement,
  FilterStateElement: FilterStateElement,
  IconElement: IconElement,
  InventoryElement: InventoryElement,
  ItemListControlsElement: ItemListControlsElement,
  ProficiencyCycleElement: ProficiencyCycleElement,
  SlideToggleElement: SlideToggleElement
});

/**
 * Dialog for configuring damage rolls.
 *
 * @param {DamageRollProcessConfiguration} [config={}]        Initial roll configuration.
 * @param {BasicRollMessageConfiguration} [message={}]        Message configuration.
 * @param {BasicRollConfigurationDialogOptions} [options={}]  Dialog rendering options.
 */
class DamageRollConfigurationDialog extends RollConfigurationDialog {

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    formulas: {
      template: "systems/dnd5e/templates/dice/damage-formulas.hbs"
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static get rollType() {
    return CONFIG.Dice.DamageRoll;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareButtonsContext(context, options) {
    const allowCritical = this.config.critical?.allow !== false;
    const defaultCritical = allowCritical && (this.options.defaultButton === "critical");
    context.buttons = {
      critical: {
        default: defaultCritical,
        icon: '<i class="fa-solid fa-bomb" inert></i>',
        label: game.i18n.localize("DND5E.CriticalHit")
      },
      normal: {
        default: !defaultCritical,
        icon: '<i class="fa-solid fa-dice" inert></i>',
        label: game.i18n.localize(allowCritical ? "DND5E.Normal" : "DND5E.Roll")
      }
    };
    if ( !allowCritical ) delete context.buttons.critical;
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareFormulasContext(context, options) {
    context = await super._prepareFormulasContext(context, options);
    const allTypes = foundry.utils.mergeObject(CONFIG.DND5E.damageTypes, CONFIG.DND5E.healingTypes, { inplace: false });
    context.rolls = context.rolls.map(({ roll }) => ({
      roll,
      damageConfig: allTypes[roll.options.type] ?? allTypes[roll.options.types?.[0]],
      damageTypes: roll.options.types?.length > 1 ? Object.entries(allTypes).map(([key, config]) => {
        if ( !roll.options.types?.includes(key) ) return null;
        return { value: key, label: config.label };
      }).filter(_ => _) : null
    }));
    return context;
  }

  /* -------------------------------------------- */
  /*  Roll Handling                               */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _buildConfig(config, formData, index) {
    config = super._buildConfig(config, formData, index);
    const damageType = formData?.get(`roll.${index}.damageType`);
    if ( damageType ) config.options.type = damageType;
    return config;
  }

  /* -------------------------------------------- */

  /** @override */
  _finalizeRolls(action) {
    return this.rolls.map(roll => {
      roll.options.isCritical = action === "critical";
      roll.configureDamage({ critical: this.config.critical });
      return roll;
    });
  }
}

var _module$j = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AttackRollConfigurationDialog: AttackRollConfigurationDialog,
  D20RollConfigurationDialog: D20RollConfigurationDialog,
  DamageRollConfigurationDialog: DamageRollConfigurationDialog,
  RollConfigurationDialog: RollConfigurationDialog,
  SkillToolRollConfigurationDialog: SkillToolRollConfigurationDialog
});

class ContainerSheet extends ItemSheet5e {

  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      width: 600,
      height: 540,
      scrollY: ["dnd5e-inventory .inventory-list"],
      tabs: [{navSelector: ".tabs", contentSelector: ".sheet-body", initial: "contents"}],
      dragDrop: [
        {dragSelector: "[data-effect-id]", dropSelector: ".effects-list"},
        {dragSelector: ".advancement-item", dropSelector: ".advancement"},
        {dragSelector: ".items-list .item", dropSelector: null},
        {dragSelector: ".containers .container", dropSelector: null}
      ],
      elements: {
        inventory: "dnd5e-inventory"
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get template() {
    return "systems/dnd5e/templates/items/container.hbs";
  }

  /* -------------------------------------------- */

  /**
   * IDs for items on the sheet that have been expanded.
   * @type {Set<string>}
   * @protected
   */
  _expanded = new Set();

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options={}) {
    const context = await super.getData(options);

    context.items = Array.from(await this.item.system.contents);
    context.capacity = await this.item.system.computeCapacity();
    context.itemContext = {};

    for ( const item of context.items ) {
      const ctx = context.itemContext[item.id] ??= {};
      ctx.totalWeight = (await item.system.totalWeight).toNearest(0.1);
      ctx.isExpanded = this._expanded.has(item.id);
      ctx.isStack = item.system.quantity > 1;
      ctx.expanded = this._expanded.has(item.id) ? await item.getChatData({secrets: this.item.isOwner}) : null;
    }
    context.isContainer = true;
    context.inventory = {
      contents: {
        label: "DND5E.Contents",
        items: context.items
      }
    };

    context.items = context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));

    return context;
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDragStart(event) {
    const li = event.currentTarget;
    if ( event.target.classList.contains("content-link") ) return;
    if ( !li.dataset.itemId ) return super._onDragStart(event);

    const item = await this.item.system.getContainedItem(li.dataset.itemId);
    const dragData = item?.toDragData();
    if ( !dragData ) return;

    // Set data transfer
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    if ( !["Item", "Folder"].includes(data.type) ) return super._onDrop(event, data);

    if ( Hooks.call("dnd5e.dropItemSheetData", this.item, this, data) === false ) return;

    if ( data.type === "Folder" ) return this._onDropFolder(event, data);
    return this._onDropItem(event, data);
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of Folder data onto the Container sheet.
   * @param {DragEvent} event              The concluding DragEvent which contains the drop data.
   * @param {object} data                  The data transfer extracted from the event.
   * @returns {Promise<Item5e[]>}          The created Item objects.
   */
  async _onDropFolder(event, data) {
    const folder = await Folder.implementation.fromDropData(data);
    if ( !this.item.isOwner || (folder.type !== "Item") ) return [];

    let recursiveWarning = false;
    const parentContainers = await this.item.system.allContainers();
    const containers = new Set();

    let items = await Promise.all(folder.contents.map(async item => {
      if ( !(item instanceof Item) ) item = await fromUuid(item.uuid);
      if ( item.system.container === this.item.id ) return;
      if ( (this.item.uuid === item.uuid) || parentContainers.includes(item) ) {
        recursiveWarning = true;
        return;
      }
      if ( item.type === "container" ) containers.add(item.id);
      return item;
    }));
    items = items.filter(i => i && !containers.has(i.system.container));

    // Display recursive warning, but continue with any remaining items
    if ( recursiveWarning ) ui.notifications.warn("DND5E.ContainerRecursiveError", { localize: true });
    if ( !items.length ) return [];

    // Create any remaining items
    const toCreate = await Item5e.createWithContents(items, {
      container: this.item,
      transformAll: (itemData, options) => this._onDropSingleItem(itemData, { ...options, event })
    });
    if ( this.item.folder ) toCreate.forEach(d => d.folder = this.item.folder.id);
    return Item5e.createDocuments(toCreate, {pack: this.item.pack, parent: this.item.parent, keepId: true});
  }

  /* -------------------------------------------- */

  /**
   * Handle the dropping of Item data onto an Item Sheet.
   * @param {DragEvent} event              The concluding DragEvent which contains the drop data.
   * @param {object} data                  The data transfer extracted from the event.
   * @returns {Promise<Item5e[]|boolean>}  The created Item objects or `false` if it couldn't be created.
   * @protected
   */
  async _onDropItem(event, data) {
    const item = await Item.implementation.fromDropData(data);
    const behavior = this._dropBehavior(event, data);
    if ( !this.item.isOwner || !item || (behavior === "none") ) return false;

    // If item already exists in this container, just adjust its sorting
    if ( (behavior === "move") && (item.system.container === this.item.id) ) {
      return this._onSortItem(event, item);
    }

    // Prevent dropping containers within themselves
    const parentContainers = await this.item.system.allContainers();
    if ( (this.item.uuid === item.uuid) || parentContainers.includes(item) ) {
      ui.notifications.error("DND5E.ContainerRecursiveError", { localize: true });
      return;
    }

    // If item already exists in same DocumentCollection, just adjust its container property
    if ( (behavior === "move") && (item.actor === this.item.actor) && (item.pack === this.item.pack) ) {
      return item.update({ folder: this.item.folder, "system.container": this.item.id });
    }

    // Otherwise, create a new item & contents in this context
    const toCreate = await Item5e.createWithContents([item], {
      container: this.item,
      transformAll: (itemData, options) => this._onDropSingleItem(itemData, { ...options, event })
    });
    if ( this.item.folder ) toCreate.forEach(d => d.folder = this.item.folder.id);
    const created = Item5e.createDocuments(toCreate, { pack: this.item.pack, parent: this.item.actor, keepId: true });
    if ( behavior === "move" ) item.delete({ deleteContents: true });
    return created;
  }

  /* -------------------------------------------- */

  /**
   * Process a single item when dropping into the container.
   * @param {object} itemData           The item data to create.
   * @param {object} options
   * @param {string} options.container  ID of the container to create the items.
   * @param {number} options.depth      Current depth of the item being created.
   * @param {DragEvent} options.event   The concluding DragEvent which provided the drop data.
   * @returns {Promise<object|false>}   The item data to create after processing, or false if the item should not be
   *                                    created or creation has been otherwise handled.
   * @protected
   */
  async _onDropSingleItem(itemData, { container, depth, event }) {
    if ( itemData.type === "spell" ) {
      const scroll = await Item5e.createScrollFromSpell(itemData);
      return scroll?.toObject?.() ?? false;
    }

    if ( this.item.actor && (container === this.item.id) ) {
      const result = await this.item.actor.sheet._onDropStackConsumables(itemData, { container });
      if ( result ) return false;
    }

    return itemData;
  }

  /* -------------------------------------------- */

  /**
   * Handle a drop event for an existing contained Item to sort it relative to its siblings.
   * @param {DragEvent} event  The concluding DragEvent.
   * @param {Item5e} item      The item that needs to be sorted.
   * @protected
   */
  async _onSortItem(event, item) {
    const dropTarget = event.target.closest("[data-item-id]");
    if ( !dropTarget ) return;
    const contents = await this.item.system.contents;
    const target = contents.get(dropTarget.dataset.itemId);

    // Don't sort on yourself
    if ( item.id === target.id ) return;

    // Identify sibling items based on adjacent HTML elements
    const siblings = [];
    for ( const el of dropTarget.parentElement.children ) {
      const siblingId = el.dataset.itemId;
      if ( siblingId && (siblingId !== item.id) ) siblings.push(contents.get(siblingId));
    }

    // Perform the sort
    const sortUpdates = SortingHelpers.performIntegerSort(item, {target, siblings});
    const updateData = sortUpdates.map(u => {
      const update = u.update;
      update._id = u.target.id;
      return update;
    });

    // Perform the update
    Item.updateDocuments(updateData, {pack: this.item.pack, parent: this.item.actor});
  }
}

class ContainerSheet2 extends ItemSheetV2Mixin(ContainerSheet) {
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["dnd5e2", "sheet", "item"],
      width: 500,
      height: "auto",
      resizable: false,
      scrollY: [".tab.active"]
    });
  }

  /**
   * The container's cached contents.
   * @type {Item5e[]}
   * @protected
   */
  _items;

  /* -------------------------------------------- */

  /** @override */
  get template() {
    return "systems/dnd5e/templates/items/item-sheet-2.hbs";
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = await super.getData(options);

    context.items = [];
    context.itemContext = {};
    context.encumbrance = await this.item.system.computeCapacity();
    context.isContainer = true;

    if ( !Number.isFinite(context.encumbrance.max) ) context.encumbrance.maxLabel = "&infin;";

    // Contents
    for ( const item of await this.item.system.contents ) {
      const ctx = context.itemContext[item.id] ??= {};
      ctx.totalWeight = (await item.system.totalWeight).toNearest(0.1);
      ctx.isExpanded = this._expanded.has(item.id);
      ctx.isStack = item.system.quantity > 1;
      ctx.expanded = this._expanded.has(item.id) ? await item.getChatData({ secrets: this.item.isOwner }) : null;
      context.items.push(item);
    }
    context.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
    context.rollableClass = this.isEditable ? "rollable" : "";

    const inventory = {};
    const inventoryTypes = Object.entries(CONFIG.Item.dataModels)
      .filter(([, model]) => model.metadata?.inventoryItem)
      .sort(([, lhs], [, rhs]) => lhs.metadata.inventoryOrder - rhs.metadata.inventoryOrder);

    for ( const [type] of inventoryTypes ) {
      inventory[type] = { label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: { type } };
    }

    delete inventory.containers;
    [context.items, context.containers] = context.items.partition(item => item.type === "container");
    context.inventory = Object.values(inventory);
    context.inventory.push({ label: "DND5E.Contents", items: context.items, dataset: { type: "all" } });

    for ( const container of context.containers ) {
      const ctx = context.itemContext[container.id];
      ctx.capacity = await container.system.computeCapacity();
    }

    this._items = context.items;
    return context;
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /**
   * Filter the container's contents based on the current set of filters.
   * @param {string} collection    The embedded collection name.
   * @param {Set<string>} filters  Filters to apply to the children.
   * @returns {Document[]}
   * @protected
   */
  _filterChildren(collection, filters) {
    if ( collection === "items" ) return this._filterItems(this._items, filters);
    return [];
  }

  /* -------------------------------------------- */

  /**
   * Filter the container's contents based on the current set of filters.
   * @param {Item5e[]} items       The Items to filter.
   * @param {Set<string>} filters  Filters applied to the Item list.
   * @returns {Item5e[]}
   * @protected
   */
  _filterItems(items, filters) {
    const actions = ["action", "bonus", "reaction"];
    return items.filter(item => {
      // Subclass-specific logic.
      const filtered = this._filterItem(item, filters);
      if ( filtered !== undefined ) return filtered;

      // Action usage.
      for ( const action of actions ) {
        if ( filters.has(action) && (item.system.activation?.type !== action) ) return false;
      }

      // Equipment-specific filters.
      if ( filters.has("equipped") && (item.system.equipped !== true) ) return false;
      if ( filters.has("mgc") && !item.system.properties?.has("mgc") ) return false;

      return true;
    });
  }

  /* -------------------------------------------- */

  /**
   * Determine whether an Item will be shown based on the current set of filters.
   * @param {Item5e} item          The Item.
   * @param {Set<string>} filters  Filters applied to the Item.
   * @returns {boolean|void}
   * @protected
   */
  _filterItem(item, filters) {
    if ( item.type === "container" ) return true;
  }
}

/* eslint-disable no-constructor-return */

class ItemCompendium5e extends (foundry.applications?.sidebar?.apps?.Compendium ?? Compendium) {
  constructor(...args) {
    super(...args);
    if ( game.release.version < 13 ) return new ItemCompendium5eV12(...args);
    return new ItemCompendium5eV13(...args);
  }
}

/**
 * Compendium with added support for item containers.
 */
class ItemCompendium5eV13 extends DragDropApplicationMixin(foundry.applications.sidebar?.apps?.Compendium ?? class {}) {

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    let items = this.collection;
    if ( this.collection.index ) {
      if ( !this.collection._reindexing ) this.collection._reindexing = this.collection.getIndex();
      await this.collection._reindexing;
      items = this.collection.index;
    }
    for ( const item of items ) {
      if ( items.has(item.system?.container) ) this.element?.querySelector(`[data-entry-id="${item._id}"]`)?.remove();
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _allowedDropBehaviors(event, data) {
    const allowed = new Set(["copy"]);
    if ( !data.uuid ) return allowed;
    const s = foundry.utils.parseUuid(data.uuid);
    if ( s.collection === this.collection ) allowed.add("move");
    return allowed;
  }

  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( !data.uuid ) return "copy";
    if ( (data.type !== "Folder") && (data.type !== "Item") ) return "none";
    return foundry.utils.parseUuid(data.uuid).collection === this.collection ? "move" : "copy";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _handleDroppedEntry(target, data) {
    // Obtain the dropped Document
    let item = await Item.fromDropData(data);
    const behavior = this._dropBehavior(event, data);
    if ( !item || (behavior === "none") ) return;

    // Create item and its contents if it doesn't already exist here
    if ( (behavior === "copy") || !this._entryAlreadyExists(item) ) {
      const contents = await item.system.contents;
      const toCreate = contents?.size
        ? await Item5e.createWithContents([item], { transformAll: item => item.toCompendium(item) })
        : [{ ...item.toObject(), _id: null, "system.container": null }];
      if ( toCreate.length ) {
        const folder = target?.closest("[data-folder-id]")?.dataset.folderId;
        if ( folder ) toCreate.map(d => d.folder = folder);
        [item] = await Item5e.createDocuments(toCreate, { pack: this.collection.collection, keepId: true });
      }
    }

    // Otherwise, if it is within a container, take it out
    else if ( item.system.container ) await item.update({"system.container": null});

    // Let parent method perform sorting
    super._handleDroppedEntry(target, item.toDragData());
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntry(event) {
    const { entryId } = event.target.closest("[data-entry-id]")?.dataset ?? {};
    const item = await this.collection.getDocument?.(entryId);
    if ( !item ) return;
    const mode = item.sheet?._mode ?? (this.collection.locked ? ItemSheet5e2.MODES.PLAY : ItemSheet5e2.MODES.EDIT);
    item.sheet.render(true, { mode });
  }
}

/**
 * Compendium with added support for item containers.
 * TODO: Remove when v12 support is dropped.
 */
class ItemCompendium5eV12 extends DragDropApplicationMixin(
  foundry.applications?.sidebar?.apps?.Compendium ?? Compendium
) {

  /** @inheritDoc */
  async _render(...args) {
    await super._render(...args);
    let items = this.collection;
    if ( this.collection.index ) {
      if ( !this.collection._reindexing ) this.collection._reindexing = this.collection.getIndex();
      await this.collection._reindexing;
      items = this.collection.index;
    }
    for ( const item of items ) {
      if ( items.has(item.system?.container) ) {
        this._element?.[0].querySelector(`[data-entry-id="${item._id}"]`)?.remove();
      }
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _allowedDropBehaviors(event, data) {
    const allowed = new Set(["copy"]);
    if ( !data.uuid ) return allowed;
    const s = foundry.utils.parseUuid(data.uuid);
    if ( s.collection === this.collection ) allowed.add("move");
    return allowed;
  }

  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( !data.uuid ) return "copy";
    if ( (data.type !== "Folder") && (data.type !== "Item") ) return "none";
    return foundry.utils.parseUuid(data.uuid).collection === this.collection ? "move" : "copy";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _handleDroppedEntry(target, data) {
    // Obtain the dropped Document
    let item = await Item.fromDropData(data);
    const behavior = this._dropBehavior(event, data);
    if ( !item || (behavior === "none") ) return;

    // Create item and its contents if it doesn't already exist here
    if ( (behavior === "copy") || !this._entryAlreadyExists(item) ) {
      const contents = await item.system.contents;
      const toCreate = contents?.size
        ? await Item5e.createWithContents([item], { transformAll: item => item.toCompendium(item) })
        : [{ ...item.toObject(), _id: null, "system.container": null }];
      if ( toCreate.length ) {
        const folder = target?.closest("[data-folder-id]")?.dataset.folderId;
        if ( folder ) toCreate.map(d => d.folder = folder);
        [item] = await Item5e.createDocuments(toCreate, { pack: this.collection.collection, keepId: true });
      }
    }

    // Otherwise, if it is within a container, take it out
    else if ( item.system.container ) await item.update({ "system.container": null });

    // Let parent method perform sorting
    super._handleDroppedEntry(target, item.toDragData());
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntryName(event) {
    const { entryId } = event.target.closest("[data-entry-id]")?.dataset ?? {};
    const item = await this.collection.getDocument?.(entryId);
    if ( !item ) return;
    const mode = item.sheet?._mode ?? (this.collection.locked ? ItemSheet5e2.MODES.PLAY : ItemSheet5e2.MODES.EDIT);
    item.sheet.render(true, { mode });
  }
}

/**
 * Items sidebar with added support for item containers.
 */
class ItemDirectory5e extends DragDropApplicationMixin(
  foundry.applications?.sidebar?.tabs?.ItemDirectory ?? ItemDirectory
) {

  /** @override */
  _allowedDropBehaviors(event, data) {
    const allowed = new Set(["copy"]);
    if ( !data.uuid ) return allowed;
    const fromCompendium = foundry.utils.parseUuid(data.uuid).collection instanceof CompendiumCollection;
    if ( data.type === "Folder" ) return fromCompendium ? allowed : new Set(["move"]);
    else if ( !fromCompendium ) allowed.add("move");
    return allowed;
  }

  /* -------------------------------------------- */

  /** @override */
  _defaultDropBehavior(event, data) {
    if ( !data.uuid ) return "copy";
    if ( (data.type !== "Folder") && (data.type !== "Item") ) return "none";
    const collection = foundry.utils.parseUuid(data.uuid).collection;
    return ((data.type === "Folder") && (collection instanceof Folder))
      || ((data.type === "Item") && (collection === this.collection)) ? "move" : "copy";
  }

  /* -------------------------------------------- */

  /** @override */
  _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    if ( !data.type ) return;
    const target = event.target.closest(".directory-item") || null;

    // Call the drop handler
    switch ( data.type ) {
      case "Folder":
        return this._handleDroppedFolder(target, data);
      case this.collection.documentName:
        return this._handleDroppedEntry(target, data, event);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _handleDroppedEntry(target, data, event) {
    // Obtain the dropped Document
    const behavior = this._dropBehavior(event, data);
    let item = await this._getDroppedEntryFromData(data);
    if ( (behavior === "none") || !item ) return;

    // Create item and its contents if it doesn't already exist here
    if ( (behavior === "copy") || !this._entryAlreadyExists(item) ) {
      const toCreate = await Item5e.createWithContents([item]);
      const folder = target?.closest("[data-folder-id]")?.dataset.folderId;
      if ( folder ) toCreate.map(d => d.folder = folder);
      [item] = await Item5e.createDocuments(toCreate, {keepId: true});
      if ( behavior === "move" ) fromUuid(data.uuid).then(d => d?.delete({ deleteContents: true }));
    }

    // Otherwise, if it is within a container, take it out
    else if ( item.system.container ) await item.update({"system.container": null});

    // Let parent method perform sorting
    super._handleDroppedEntry(target, item.toDragData());
  }

  /* -------------------------------------------- */

  /** @override */
  async _onClickEntryName(event) {
    const { entryId } = event.target.closest("[data-entry-id]")?.dataset ?? {};
    const item = this.collection.get(entryId);
    if ( !item ) return;
    const mode = item.sheet?._mode ?? (this.collection.locked ? ItemSheet5e2.MODES.PLAY : ItemSheet5e2.MODES.EDIT);
    item.sheet.render(true, { mode });
  }
}

/**
 * Application for configuring enchantment information for an item.
 */
class EnchantmentConfig extends DocumentSheet {
  constructor() {
    throw new Error(
      "EnchantmentConfig has been deprecated. Configuring enchanting should now be performed through the Enchant activity."
    );
  }
}

/**
 * Application for configuring summoning information for an item.
 */
class SummoningConfig extends DocumentSheet {
  constructor() {
    throw new Error(
      "SummongConfig has been deprecated. Configuring summoning should now be performed through the Summon activity."
    );
  }
}

var _module$i = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ContainerSheet: ContainerSheet,
  ContainerSheet2: ContainerSheet2,
  EnchantmentConfig: EnchantmentConfig,
  ItemCompendium5e: ItemCompendium5e,
  ItemDirectory5e: ItemDirectory5e,
  ItemSheet5e: ItemSheet5e,
  ItemSheet5e2: ItemSheet5e2,
  ItemSheetV2Mixin: ItemSheetV2Mixin,
  StartingEquipmentConfig: StartingEquipmentConfig,
  SummoningConfig: SummoningConfig
});

/**
 * @typedef JournalEditorConfiguration
 * @property {string} textKeyPath  The path to the specific HTML field being edited.
 */

/**
 * Pop out ProseMirror editor window for journal entries with multiple text areas that need editing.
 * @extends {DocumentSheet5e<ApplicationConfiguration & JournalEditorConfiguration>}
 */
class JournalEditor extends DocumentSheet5e {
  constructor(options, _options={}) {
    if ( options instanceof JournalEntryPage ) {
      foundry.utils.logCompatibilityWarning(
        "The `JournalEditor` now takes the document within its application options during construction.",
        { since: "DnD5e 4.3", until: "DnD5e 5.0" }
      );
      options = { ..._options, document: options };
    }
    super(options);
  }

  /* -------------------------------------------- */

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["journal-editor"],
    window: {
      resizable: true
    },
    form: {
      submitOnChange: true
    },
    position: {
      width: 550,
      height: 640
    },
    sheetConfig: false,
    textKeyPath: null
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    editor: {
      template: "systems/dnd5e/templates/journal/journal-editor.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return this.options.window.title ?? this.document.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const rawText = foundry.utils.getProperty(this.document, this.options.textKeyPath) ?? "";
    return foundry.utils.mergeObject(context, {
      document: this.document,
      enriched: await TextEditor.enrichHTML(rawText, {
        relativeTo: this.document, secrets: this.document.isOwner
      }),
      keyPath: this.options.textKeyPath,
      source: rawText
    });
  }
}

/**
 * Journal entry page that displays an automatically generated summary of a class along with additional description.
 */
class JournalClassPageSheet extends JournalPageSheet {

  /** @inheritDoc */
  static get defaultOptions() {
    const options = foundry.utils.mergeObject(super.defaultOptions, {
      dragDrop: [{dropSelector: ".drop-target"}],
      height: "auto",
      width: 500,
      submitOnChange: true
    });
    options.classes.push("class-journal");
    return options;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get template() {
    return `systems/dnd5e/templates/journal/page-${this.document.type}-${this.isEditable ? "edit" : "view"}.hbs`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  toc = {};

  /* -------------------------------------------- */

  /**
   * Whether this page represents a class or subclass.
   * @type {string}
   */
  get type() {
    return this.document.type;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = super.getData(options);
    context.system = context.document.system;
    context.systemFields = this.document.system.schema.fields;

    context.styleOptions = [
      { value: "", label: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.Style.Inferred") },
      { rule: true },
      { value: "2024", label: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.Style.Modern") },
      { value: "2014", label: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.Style.Legacy") }
    ];

    context.title = Object.fromEntries(
      Array.fromRange(4, 1).map(n => [`level${n}`, context.data.title.level + n - 1])
    );
    context.type = this.type;

    const linked = await fromUuid(this.document.system.item);
    context.subclasses = this.type === "class" ? await this._getSubclasses(this.document.system.subclassItems) : null;

    if ( !linked ) return context;
    context.linked = {
      document: linked,
      name: linked.name,
      lowercaseName: linked.name.toLowerCase()
    };
    const modernStyle = context.modernStyle = (context.system.style || linked.system.source.rules) === "2024";

    context.advancement = this._getAdvancement(linked, { modernStyle });
    context.enriched = await this._getDescriptions(context.document);
    context.table = await this._getTable(linked, { modernStyle });
    context.optionalTable = await this._getOptionalTable(linked, { modernStyle });
    context.features = await this._getFeatures(linked, { modernStyle });
    context.optionalFeatures = await this._getFeatures(linked, { modernStyle, optional: true });

    if ( context.subclasses?.length ) {
      for ( const subclass of context.subclasses ) {
        const initialLevel = parseInt(Object.entries(subclass.document.advancement.byLevel)
          .find(([lvl, d]) => d.length)?.[0] ?? 1);
        subclass.table = await this._getTable(subclass.document, { initialLevel, modernStyle });
        subclass.features = await this._getFeatures(subclass.document, { modernStyle });
      }
      context.subclasses.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name, game.i18n.lang));
    }

    if ( linked.system.primaryAbility ) {
      context.primaryAbility = game.i18n.getListFormatter(
        { type: linked.system.primaryAbility.all ? "conjunction" : "disjunction" }
      ).format(Array.from(linked.system.primaryAbility.value).map(v => CONFIG.DND5E.abilities[v]?.label));
    }

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Prepare features granted by various advancement types.
   * @param {Item5e} item                  Class item belonging to this journal.
   * @param {object} options
   * @param {boolean} options.modernStyle  Is the modern style being displayed?
   * @returns {object}                     Prepared advancement section.
   * @protected
   */
  _getAdvancement(item, { modernStyle }) {
    const advancement = {};

    const hp = item.advancement.byType.HitPoints?.[0];
    if ( hp ) {
      advancement.hp = {
        hitDice: modernStyle ? hp.hitDie.toUpperCase() : `1${hp.hitDie}`,
        max: hp.hitDieValue,
        average: Math.floor(hp.hitDieValue / 2) + 1
      };
    }

    const traits = item.advancement.byType.Trait ?? [];
    const makeTrait = type => {
      const advancement = traits.find(a => {
        const rep = a.representedTraits();
        if ( (rep.size > 1) || (rep.first() !== type) ) return false;
        return (a.classRestriction !== "secondary") && (a.level === 1) && (a.configuration.mode === "default");
      });
      if ( !advancement ) return game.i18n.localize("None");
      return advancement.hint || localizedList(advancement.configuration);
    };
    if ( traits.length ) {
      advancement.traits = {
        armor: makeTrait("armor"),
        weapons: makeTrait("weapon"),
        tools: makeTrait("tool"),
        saves: makeTrait("saves"),
        skills: makeTrait("skills")
      };
    }

    advancement.equipment = item.system.startingEquipmentDescription;

    return advancement;
  }

  /* -------------------------------------------- */

  /**
   * Enrich all of the entries within the descriptions object on the sheet's system data.
   * @param {JournalEntryPage} page  Journal page being enriched.
   * @returns {Promise<object>}      Object with enriched descriptions.
   * @protected
   */
  async _getDescriptions(page) {
    const descriptions = await Promise.all(Object.entries(page.system.description ?? {})
      .map(async ([id, text]) => {
        const enriched = await TextEditor.enrichHTML(text, {
          relativeTo: this.object,
          secrets: this.object.isOwner
        });
        return [id, enriched];
      })
    );
    return Object.fromEntries(descriptions);
  }

  /* -------------------------------------------- */

  /**
   * Prepare table based on non-optional GrantItem advancement & ScaleValue advancement.
   * @param {Item5e} item                      Class item belonging to this journal.
   * @param {object} options
   * @param {number} [options.initialLevel=1]  Level at which the table begins.
   * @param {boolean} options.modernStyle      Is the modern style being displayed?
   * @returns {object}                         Prepared table.
   * @protected
   */
  async _getTable(item, { initialLevel=1, modernStyle }={}) {
    const hasFeatures = !!item.advancement.byType.ItemGrant;
    const scaleValues = (item.advancement.byType.ScaleValue ?? []);
    const spellProgression = await this._getSpellProgression(item);

    const headers = [[{content: game.i18n.localize("DND5E.Level")}]];
    if ( item.type === "class" ) headers[0].push({content: game.i18n.localize("DND5E.ProficiencyBonus")});
    if ( hasFeatures ) headers[0].push({content: game.i18n.localize("DND5E.Features")});
    headers[0].push(...scaleValues.map(a => ({content: a.title})));
    if ( spellProgression ) {
      if ( spellProgression.headers.length > 1 ) {
        headers[0].forEach(h => h.rowSpan = 2);
        headers[0].push(...spellProgression.headers[0]);
        headers[1] = spellProgression.headers[1];
      } else {
        headers[0].push(...spellProgression.headers[0]);
      }
    }

    const cols = [{ class: "level", span: 1 }];
    if ( item.type === "class" ) cols.push({class: "prof", span: 1});
    if ( hasFeatures ) cols.push({class: "features", span: 1});
    if ( scaleValues.length ) cols.push({class: "scale", span: scaleValues.length});
    if ( spellProgression ) cols.push(...spellProgression.cols);

    const prepareFeature = uuid => {
      const index = fromUuidSync(uuid);
      if ( index?.type !== "feat" ) return null;
      return linkForUuid(uuid);
    };

    const rows = [];
    for ( const level of Array.fromRange((CONFIG.DND5E.maxLevel - (initialLevel - 1)), initialLevel) ) {
      let features = [];
      for ( const advancement of item.advancement.byLevel[level] ) {
        switch ( advancement.constructor.typeName ) {
          case "AbilityScoreImprovement":
            features.push(game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.Title"));
            continue;
          case "ItemGrant":
            if ( advancement.configuration.optional ) continue;
            features.push(...await Promise.all(advancement.configuration.items.map(i => prepareFeature(i.uuid))));
            break;
        }
      }
      features = features.filter(_ => _);

      // Level & proficiency bonus
      const cells = [{class: "level", content: modernStyle ? level : level.ordinalString()}];
      if ( item.type === "class" ) cells.push({class: "prof", content: `+${Proficiency.calculateMod(level)}`});
      if ( hasFeatures ) cells.push({class: "features", content: features.join(", ")});
      scaleValues.forEach(s => cells.push({class: "scale", content: s.valueForLevel(level)?.display}));
      const spellCells = spellProgression?.rows[rows.length];
      if ( spellCells ) cells.push(...spellCells);

      // Skip empty rows on subclasses
      if ( item.type === "subclass" ) {
        let displayRow = features.length || spellCells;
        if ( rows.length ) displayRow ||= rows.at(-1).some((cell, index) =>
          (cell.class === "scale") && (cell.content !== cells[index].content)
        );
        else if ( scaleValues.length ) displayRow ||= cells.filter(c => (c.class === "scale") && c.content).length;
        if ( !displayRow ) continue;
      }

      rows.push(cells);
    }

    return { headers, cols, rows };
  }

  /* -------------------------------------------- */

  /**
   * Build out the spell progression data.
   * @param {Item5e} item  Class item belonging to this journal.
   * @returns {object}     Prepared spell progression table.
   * @protected
   */
  async _getSpellProgression(item) {
    const spellcasting = foundry.utils.deepClone(item.spellcasting);
    if ( !spellcasting || (spellcasting.progression === "none") ) return null;

    const table = { rows: [] };

    if ( spellcasting.type === "leveled" ) {
      const spells = {};
      const maxSpellLevel = CONFIG.DND5E.SPELL_SLOT_TABLE[CONFIG.DND5E.SPELL_SLOT_TABLE.length - 1].length;
      Array.fromRange(maxSpellLevel, 1).forEach(l => spells[`spell${l}`] = {});

      let largestSlot;
      for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1).reverse() ) {
        const progression = { slot: 0 };
        spellcasting.levels = level;
        Actor5e.computeClassProgression(progression, item, { spellcasting });
        Actor5e.prepareSpellcastingSlots(spells, "leveled", progression);

        if ( !largestSlot ) largestSlot = Object.values(spells).reduce((slot, { max, level }) => {
          if ( !max ) return slot;
          return Math.max(slot, level || -1);
        }, -1);

        table.rows.push(Array.fromRange(largestSlot, 1).map(spellLevel => {
          return {class: "spell-slots", content: spells[`spell${spellLevel}`]?.max || "&mdash;"};
        }));
      }

      // Prepare headers & columns
      table.headers = [
        [{content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlotsPerSpellLevel"), colSpan: largestSlot}],
        Array.fromRange(largestSlot, 1).map(spellLevel => ({content: spellLevel.ordinalString()}))
      ];
      table.cols = [{class: "spellcasting", span: largestSlot}];
      table.rows.reverse();
    }

    else if ( spellcasting.type === "pact" ) {
      const spells = { pact: {} };

      table.headers = [[
        { content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlots") },
        { content: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlotLevel") }
      ]];
      table.cols = [{class: "spellcasting", span: 2}];

      // Loop through each level, gathering "Spell Slots" & "Slot Level" for each one
      for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
        const progression = { pact: 0 };
        spellcasting.levels = level;
        Actor5e.computeClassProgression(progression, item, { spellcasting });
        Actor5e.prepareSpellcastingSlots(spells, "pact", progression);
        table.rows.push([
          { class: "spell-slots", content: `${spells.pact.max}` },
          { class: "slot-level", content: spells.pact.level.ordinalString() }
        ]);
      }
    }

    else {
      /**
       * A hook event that fires to generate the table for custom spellcasting types.
       * The actual hook names include the spellcasting type (e.g. `dnd5e.buildPsionicSpellcastingTable`).
       * @param {object} table                          Table definition being built. *Will be mutated.*
       * @param {Item5e} item                           Class for which the spellcasting table is being built.
       * @param {SpellcastingDescription} spellcasting  Spellcasting descriptive object.
       * @function dnd5e.buildSpellcastingTable
       * @memberof hookEvents
       */
      Hooks.callAll(
        `dnd5e.build${spellcasting.type.capitalize()}SpellcastingTable`, table, item, spellcasting
      );
    }

    return table;
  }

  /* -------------------------------------------- */

  /**
   * Prepare options table based on optional GrantItem advancement.
   * @param {Item5e} item                  Class item belonging to this journal.
   * @param {object} options
   * @param {boolean} options.modernStyle  Is the modern style being displayed?
   * @returns {object|null}                Prepared optional features table.
   * @protected
   */
  async _getOptionalTable(item, { modernStyle }) {
    const headers = [[
      { content: game.i18n.localize("DND5E.Level") },
      { content: game.i18n.localize("DND5E.Features") }
    ]];

    const cols = [
      { class: "level", span: 1 },
      { class: "features", span: 1 }
    ];

    const prepareFeature = uuid => {
      const index = fromUuidSync(uuid);
      if ( index?.type !== "feat" ) return null;
      return linkForUuid(uuid);
    };

    const rows = [];
    for ( const level of Array.fromRange(CONFIG.DND5E.maxLevel, 1) ) {
      let features = [];
      for ( const advancement of item.advancement.byLevel[level] ) {
        switch ( advancement.constructor.typeName ) {
          case "ItemGrant":
            if ( !advancement.configuration.optional ) continue;
            features.push(...await Promise.all(advancement.configuration.items.map(i => prepareFeature(i.uuid))));
            break;
        }
      }
      features = features.filter(_ => _);
      if ( !features.length ) continue;

      // Level & proficiency bonus
      const cells = [
        { class: "level", content: modernStyle ? level : level.ordinalString() },
        { class: "features", content: features.join(", ") }
      ];
      rows.push(cells);
    }
    if ( !rows.length ) return null;

    return { headers, cols, rows };
  }

  /* -------------------------------------------- */

  /**
   * Fetch data for each class feature listed.
   * @param {Item5e} item                       Class or subclass item belonging to this journal.
   * @param {object} options
   * @param {boolean} options.modernStyle       Is the modern style being displayed?
   * @param {boolean} [options.optional=false]  Should optional features be fetched rather than required features?
   * @returns {object[]}   Prepared features.
   * @protected
   */
  async _getFeatures(item, { modernStyle, optional=false }) {
    const prepareFeature = async (f, level) => {
      const document = await fromUuid(f.uuid);
      if ( document?.type !== "feat" ) return null;
      return {
        document, level,
        name: modernStyle ? game.i18n.format("JOURNALENTRYPAGE.DND5E.Class.Features.Name", {
          name: document.name, level: formatNumber(level)
        }) : document.name,
        description: await TextEditor.enrichHTML(document.system.description.value, {
          relativeTo: item, secrets: false
        })
      };
    };

    let features = [];
    const itemGrants = Array.from(item.advancement.byType.ItemGrant ?? []);
    for ( const advancement of itemGrants ) {
      if ( !!advancement.configuration.optional !== optional ) continue;
      features.push(...advancement.configuration.items.map(f => prepareFeature(f, advancement.level)));
    }

    const asiLevels = (item.advancement.byType.AbilityScoreImprovement ?? []).map(a => a.level).sort((a, b) => a - b);
    if ( asiLevels.length ) {
      const [firstLevel, ...otherLevels] = asiLevels;
      const name = game.i18n.localize("DND5E.ADVANCEMENT.AbilityScoreImprovement.Journal.Name");
      features.push({
        description: game.i18n.format(
          `DND5E.ADVANCEMENT.AbilityScoreImprovement.Journal.Description${modernStyle ? "Modern" : "Legacy"}`,
          {
            class: item.name,
            firstLevel: formatNumber(firstLevel),
            firstLevelOrdinal: formatNumber(firstLevel, { ordinal: true }),
            maxAbilityScore: formatNumber(CONFIG.DND5E.maxAbilityScore),
            otherLevels: game.i18n.getListFormatter({ style: "long" }).format(otherLevels.map(l => formatNumber(l))),
            otherLevelsOrdinal: game.i18n.getListFormatter({ style: "long" })
              .format(otherLevels.map(l => formatNumber(l, { ordinal: true })))
          }
        ),
        level: asiLevels[0],
        name: modernStyle ? game.i18n.format("JOURNALENTRYPAGE.DND5E.Class.Features.Name", {
          name: name, level: formatNumber(firstLevel)
        }) : name
      });
    }

    features = await Promise.all(features);
    return features.filter(f => f).sort((lhs, rhs) => lhs.level - rhs.level);
  }

  /* -------------------------------------------- */

  /**
   * Fetch each subclass and their features.
   * @param {string[]} uuids   UUIDs for the subclasses to fetch.
   * @returns {object[]|null}  Prepared subclasses.
   * @protected
   */
  async _getSubclasses(uuids) {
    const prepareSubclass = async uuid => {
      const document = await fromUuid(uuid);
      return this._getSubclass(document);
    };

    const subclasses = await Promise.all(uuids.map(prepareSubclass));
    return subclasses.length ? subclasses : null;
  }

  /* -------------------------------------------- */

  /**
   * Prepare data for the provided subclass.
   * @param {Item5e} item  Subclass item being prepared.
   * @returns {object}     Presentation data for this subclass.
   * @protected
   */
  async _getSubclass(item) {
    return {
      document: item,
      name: item.name,
      description: await TextEditor.enrichHTML(item.system.description.value, {
        relativeTo: item, secrets: false
      })
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderInner(...args) {
    const html = await super._renderInner(...args);
    this.toc = JournalEntryPage.buildTOC(html.get());
    return html;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html[0].querySelectorAll(".item-delete").forEach(e => {
      e.addEventListener("click", this._onDeleteItem.bind(this));
    });
    html[0].querySelectorAll(".launch-text-editor").forEach(e => {
      e.addEventListener("click", this._onLaunchTextEditor.bind(this));
    });
  }

  /* -------------------------------------------- */

  /**
   * Handle deleting a dropped item.
   * @param {Event} event  The triggering click event.
   * @returns {JournalClassSummary5ePageSheet}
   */
  async _onDeleteItem(event) {
    event.preventDefault();
    const container = event.currentTarget.closest("[data-item-uuid]");
    const uuidToDelete = container?.dataset.itemUuid;
    if ( !uuidToDelete ) return;
    switch ( container.dataset.itemType ) {
      case "linked":
        await this.document.update({"system.item": ""});
        return this.render();
      case "subclass":
        const itemSet = this.document.system.subclassItems;
        itemSet.delete(uuidToDelete);
        await this.document.update({"system.subclassItems": Array.from(itemSet)});
        return this.render();
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle launching the individual text editing window.
   * @param {Event} event  The triggering click event.
   */
  _onLaunchTextEditor(event) {
    event.preventDefault();
    const textKeyPath = event.currentTarget.dataset.target;
    const label = event.target.closest(".form-group").querySelector("label");
    const editor = new JournalEditor({ document: this.document, textKeyPath, window: { title: label?.innerText } });
    editor.render(true);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragDrop() {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);

    if ( data?.type !== "Item" ) return false;
    const item = await Item.implementation.fromDropData(data);
    const type = this.type === item.type ? "linked" : item.type;
    switch ( type ) {
      case "linked":
        await this.document.update({"system.item": item.uuid});
        return this.render();
      case "subclass":
        const itemSet = this.document.system.subclassItems;
        itemSet.add(item.uuid);
        await this.document.update({"system.subclassItems": Array.from(itemSet)});
        return this.render();
      default:
        return false;
    }
  }
}

/**
 * Journal entry page that displays a controls for editing map markers.
 */
class JournalMapLocationPageSheet extends JournalTextPageSheet {

  /** @inheritDoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("map");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get template() {
    return `templates/journal/page-text-${this.isEditable ? "edit" : "view"}.html`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderInner(...args) {
    const jQuery = await super._renderInner(...args);
    const editingHeader = jQuery[0].querySelector(".journal-header");
    const viewingHeader = jQuery[0].querySelector(":is(h1, h2, h3)");

    if ( editingHeader ) {
      const input = document.createElement("input");
      input.name = "system.code";
      input.type = "text";
      input.value = this.document.system.code ?? "";
      editingHeader.insertAdjacentElement("afterbegin", input);
    }

    else if ( viewingHeader && this.document.system.code ) {
      viewingHeader.dataset.mapLocationCode = this.document.system.code;
    }

    return jQuery;
  }
}

/**
 * Journal entry page that displays a controls for editing rule page tooltip & type.
 */
class JournalRulePageSheet extends JournalTextPageSheet {

  /** @inheritDoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("rule");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get template() {
    return this.isEditable
      ? "systems/dnd5e/templates/journal/page-rule-edit.hbs"
      : "templates/journal/page-text-view.html";
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = await super.getData(options);
    context.CONFIG = CONFIG.DND5E;
    context.enrichedTooltip = await TextEditor.enrichHTML(this.object.system.tooltip, {
      relativeTo: this.object,
      secrets: this.object.isOwner
    });
    return context;
  }
}

/**
 * Variant of the standard journal sheet to handle custom TOC numbering.
 */
class JournalSheet5e extends JournalSheet {
  /** @inheritDoc */
  static get defaultOptions() {
    const options = super.defaultOptions;
    options.classes.push("dnd5e2-journal");
    return options;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.on("pointerdown", event => {
      if ( (event.button === 1) && document.getElementById("tooltip")?.classList.contains("active") ) {
        event.preventDefault();
      }
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _getPageData() {
    const pageData = super._getPageData();

    let adjustment = 0;
    for ( const page of pageData ) {
      const pageDocument = this.document.pages.get(page._id);
      let needsAdjustment = true;
      const numbering = pageDocument.system.adjustTOCNumbering?.(page.number);
      if ( numbering ) {
        page.number = numbering.number;
        adjustment += numbering.adjustment ?? 0;
        needsAdjustment = false;
      }
      if ( needsAdjustment ) page.number += adjustment;
    }

    return pageData;
  }

  /* -------------------------------------------- */

  /**
   * Add class to journal pages also.
   * @param {JournalPageSheet} page  The journal page application.
   * @param {jQuery} jQuery          The rendered Application HTML.
   * @param {object} context         Rendering context provided.
   */
  static onRenderJournalPageSheet(page, jQuery, context) {
    if ( page.object.parent.sheet instanceof JournalSheet5e ) {
      let element;
      if ( context.editable ) element = jQuery[0];
      else element = jQuery[0].parentElement;
      element?.classList.add("dnd5e2-journal");
    }
  }
}

/**
 * Custom items collection to hide items in containers automatically.
 */
class Items5e extends Items {
  /** @override */
  _getVisibleTreeContents(entry) {
    return this.contents.filter(c => c.visible && !this.has(c.system?.container));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async importFromCompendium(pack, id, updateData={}, options={}) {
    const created = await super.importFromCompendium(pack, id, updateData, options);

    const item = await pack.getDocument(id);
    const contents = await item.system.contents;
    if ( contents ) {
      const fromOptions = foundry.utils.mergeObject({ clearSort: false }, options);
      const toCreate = await Item5e.createWithContents(contents, {
        container: created, keepId: options.keepId, transformAll: item => this.fromCompendium(item, fromOptions)
      });
      await Item5e.createDocuments(toCreate, {fromCompendium: true, keepId: true});
    }

    return created;
  }
}

/**
 * Application for configuring a single unlinked spell in a spell list.
 */
class SpellsUnlinkedConfig extends DocumentSheet5e {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["unlinked-spell-config"],
    form: {
      submitOnChange: true
    },
    position: {
      width: 400
    },
    sheetConfig: false,
    unlinkedId: null
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    spell: {
      template: "systems/dnd5e/templates/journal/page-spell-list-unlinked-spell.hbs"
    },
    source: {
      template: "systems/dnd5e/templates/journal/page-spell-list-unlinked-source.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  get title() {
    return game.i18n.localize("JOURNALENTRYPAGE.DND5E.SpellList.UnlinkedSpells.Configuration");
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = {
      ...await super._prepareContext(options),
      ...this.document.system.unlinkedSpells.find(u => u._id === this.options.unlinkedId),
      fields: this.document.system.schema.fields.unlinkedSpells.element.fields,
      spellLevelOptions: Object.entries(CONFIG.DND5E.spellLevels).map(([value, label]) => ({ value, label })),
      spellSchoolOptions: Object.entries(CONFIG.DND5E.spellSchools).map(([value, { label }]) => ({ value, label }))
    };
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const unlinkedSpells = this.document.system.toObject().unlinkedSpells;
    const editing = unlinkedSpells.find(s => s._id === this.options.unlinkedId);
    foundry.utils.mergeObject(editing, submitData);
    return { system: { unlinkedSpells: unlinkedSpells } };
  }
}

/**
 * Journal entry page the displays a list of spells for a class, subclass, background, or something else.
 */
class JournalSpellListPageSheet extends JournalPageSheet {

  /** @inheritDoc */
  static get defaultOptions() {
    const options = foundry.utils.mergeObject(super.defaultOptions, {
      dragDrop: [{dropSelector: "form"}],
      submitOnChange: true,
      width: 700,
      displayAsTable: false,
      embedRendering: false,
      grouping: null
    });
    options.classes.push("spells");
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Different ways in which spells can be grouped on the sheet.
   * @type {Record<string, string>}
   */
  static get GROUPING_MODES() {
    return dnd5e.dataModels.journal.SpellListJournalPageData.GROUPING_MODES;
  }

  /* -------------------------------------------- */

  /**
   * Currently selected grouping mode.
   * @type {string|null}
   */
  grouping = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  get template() {
    if ( this.options.displayAsTable ) return "systems/dnd5e/templates/journal/page-spell-list-table.hbs";
    return `systems/dnd5e/templates/journal/page-spell-list-${this.isEditable ? "edit" : "view"}.hbs`;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = super.getData(options);
    context.CONFIG = CONFIG.DND5E;
    context.system = context.document.system;
    context.embedRendering = this.options.embedRendering ?? false;

    context.title = Object.fromEntries(Array.fromRange(4, 1).map(n => [`level${n}`, context.data.title.level + n - 1]));

    context.description = await TextEditor.enrichHTML(context.system.description.value, { relativeTo: this });
    if ( context.description === "<p></p>" ) context.description = "";

    context.GROUPING_MODES = this.constructor.GROUPING_MODES;
    context.grouping = this.grouping || this.options.grouping || context.system.grouping;

    context.spells = await this.prepareSpells(context.grouping);

    context.sections = {};
    for ( const data of context.spells ) {
      const spell = data.spell ?? data.unlinked;
      let section;
      switch ( context.grouping ) {
        case "level":
          const level = spell.system.level;
          section = context.sections[level] ??= { header: CONFIG.DND5E.spellLevels[level], spells: [] };
          break;
        case "school":
          const school = spell.system.school;
          section = context.sections[school] ??= { header: CONFIG.DND5E.spellSchools[school]?.label, spells: [] };
          break;
        case "alphabetical":
          const letter = spell.name.slice(0, 1).toLowerCase();
          section = context.sections[letter] ??= { header: letter.toUpperCase(), spells: [] };
          break;
        default:
          continue;
      }
      section.spells.push(data);
    }
    if ( context.grouping === "school" ) context.sections = sortObjectEntries(context.sections, "header");

    if ( this.options.displayAsTable ) Object.values(context.sections).forEach(section => {
      const spells = section.spells.map(s => linkForUuid(s.spell?.uuid)).filter(_ => _);
      section.spellList = game.i18n.getListFormatter({ type: "unit" }).format(spells);
    });

    return context;
  }

  /* -------------------------------------------- */

  /**
   * Load indices with necessary information for spells.
   * @param {string} grouping  Grouping mode to respect.
   * @returns {object[]}
   */
  async prepareSpells(grouping) {
    let fields;
    switch ( grouping ) {
      case "level": fields = ["system.level"]; break;
      case "school": fields = ["system.school"]; break;
      default: fields = []; break;
    }

    const unlinkedData = {};
    const uuids = new Set(this.document.system.spells);
    for ( const unlinked of this.document.system.unlinkedSpells ) {
      if ( unlinked.source.uuid ) {
        uuids.add(unlinked.source.uuid);
        unlinkedData[unlinked.source.uuid] = unlinked;
      }
    }

    let collections = new Collection();
    const remappedUuids = new Set();
    for ( const baseUuid of uuids ) {
      const { collection, uuid } = parseUuid(baseUuid);
      remappedUuids.add(uuid);
      if ( collection && !collections.has(collection) ) {
        if ( collection instanceof Items5e ) collections.set(collection, collection);
        else collections.set(collection, collection.getIndex({ fields }));
      } else if ( !collection ) uuids.delete(baseUuid);
    }

    const spells = (await Promise.all(collections.values())).flatMap(c => c.filter(s => remappedUuids.has(s.uuid)));

    for ( const unlinked of this.document.system.unlinkedSpells ) {
      if ( !uuids.has(unlinked.source.uuid) ) spells.push({ unlinked });
    }

    return spells
      .map(spell => {
        const data = spell.unlinked ? spell : { spell };
        data.unlinked ??= unlinkedData[data.spell?.uuid];
        data.name = data.spell?.name ?? data.unlinked?.name ?? "";
        if ( data.spell ) {
          data.display = linkForUuid(data.spell.uuid, {
            tooltip: '<section class="loading"><i class="fas fa-spinner fa-spin-pulse"></i></section>'
          });
        } else {
          data.display = `<span class="unlinked-spell"
            data-tooltip="${data.unlinked.source.label}">${data.unlinked.name ?? "—"}*</span>`;
        }
        return data;
      })
      .sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang));
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(jQuery) {
    super.activateListeners(jQuery);
    const [html] = jQuery;

    html.querySelector('[name="grouping"]')?.addEventListener("change", event => {
      this.grouping = (event.target.value === this.document.system.grouping) ? null : event.target.value;
      this.object.parent.sheet.render();
    });
    html.querySelectorAll("[data-action]").forEach(e => {
      e.addEventListener("click", this._onAction.bind(this));
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _canDragDrop() {
    return this.isEditable;
  }

  /* -------------------------------------------- */

  /**
   * Handle performing an action.
   * @param {PointerEvent} event  This triggering click event.
   */
  async _onAction(event) {
    event.preventDefault();
    const { action } = event.target.dataset;

    const { itemUuid, unlinkedId } = event.target.closest(".item")?.dataset ?? {};
    switch ( action ) {
      case "add-unlinked":
        await this.document.update({"system.unlinkedSpells": [...this.document.system.unlinkedSpells, {}]});
        const id = this.document.toObject().system.unlinkedSpells.pop()._id;
        new SpellsUnlinkedConfig({ document: this.document, unlinkedId: id }).render(true);
        break;
      case "delete":
        if ( itemUuid ) {
          const spellSet = this.document.system.spells.filter(s => s !== itemUuid);
          await this.document.update({"system.spells": Array.from(spellSet)});
        } else if ( unlinkedId ) {
          const unlinkedSet = this.document.system.unlinkedSpells.filter(s => s._id !== unlinkedId);
          await this.document.update({"system.unlinkedSpells": Array.from(unlinkedSet)});
        }
        this.render();
        break;
      case "edit-unlinked":
        if ( unlinkedId ) new SpellsUnlinkedConfig({ document: this.document, unlinkedId }).render(true);
        break;
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onDrop(event) {
    const data = TextEditor.getDragEventData(event);
    let spells;
    switch ( data?.type ) {
      case "Folder":
        spells = (await Folder.implementation.fromDropData(data))?.contents;
        break;
      case "Item":
        spells = [await Item.implementation.fromDropData(data)];
        break;
      default: return false;
    }

    const spellUuids = this.document.system.spells;
    spells = spells.filter(item => (item.type === "spell") && !spellUuids.has(item.uuid));
    if ( !spells.length ) return false;

    spells.forEach(i => spellUuids.add(i.uuid));
    await this.document.update({"system.spells": Array.from(spellUuids)});
    this.render();
  }
}

/* eslint-disable no-constructor-return */
class TableOfContentsCompendium extends (foundry.applications?.sidebar?.apps?.Compendium ?? Compendium) {
  constructor(...args) {
    super(...args);
    if ( game.release.version < 13 ) return new TableOfContentsCompendiumV12(...args);
    return new TableOfContentsCompendiumV13(...args);
  }
}

/**
 * Compendium that renders pages as a table of contents.
 */
class TableOfContentsCompendiumV13 extends (foundry.applications.sidebar?.apps?.Compendium ?? class {}) {
  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["table-of-contents"],
    window: {
      resizable: true,
      contentTag: "article"
    },
    position: {
      width: 800,
      height: 950
    },
    actions: {
      activateEntry: this.prototype._onClickLink
    }
  };

  /* -------------------------------------------- */

  /** @override */
  static PARTS = {
    article: {
      root: true,
      template: "systems/dnd5e/templates/journal/table-of-contents.hbs"
    }
  };

  /* -------------------------------------------- */

  /**
   * Position of pages based on type.
   * @enum {number}
   */
  static TYPES = {
    chapter: 0,
    appendix: 100
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  _configureRenderParts(options) {
    // Skip normal compendium render parts logic.
    return foundry.utils.deepClone(this.constructor.PARTS);
  }

  /* -------------------------------------------- */

  /** @override */
  _createContextMenus() {
    foundry.applications.ui.ContextMenu.create(this, this.element, "[data-entry-id]", this._getEntryContextOptions(), {
      jQuery: false,
      fixed: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop({
      dragSelector: "[data-document-id]",
      dropSelector: "article",
      permissions: {
        dragstart: this._canDragStart.bind(this),
        drop: this._canDragDrop.bind(this)
      },
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    const documents = await this.collection.getDocuments();

    context.chapters = [];
    const specialEntries = [];
    for ( const entry of documents ) {
      const flags = entry.flags?.dnd5e;
      if ( !flags ) continue;
      const type = flags.type ?? "chapter";

      if ( type === "header" ) {
        const page = entry.pages.contents[0];
        context.header = {
          title: flags.title ?? page?.name,
          content: page?.text.content
        };
        continue;
      }

      const data = {
        type, flags,
        id: entry.id,
        name: flags.title ?? entry.name,
        pages: Array.from(entry.pages).map(({ flags, id, name, sort }) => ({
          id, sort, flags,
          name: flags.dnd5e?.title ?? name,
          entryId: entry.id
        }))
      };

      if ( type === "special" ) {
        data.showPages = flags.showPages ?? !flags.append;
        specialEntries.push(data);
      } else {
        data.order = (this.constructor.TYPES[type] ?? 200) + (flags.position ?? 0);
        data.showPages = (flags.showPages !== false) && ((flags.showPages === true) || (type === "chapter"));
        context.chapters.push(data);
      }
    }

    context.chapters.sort((lhs, rhs) => lhs.order - rhs.order);
    for ( const entry of specialEntries ) {
      const append = entry.flags.append;
      const order = entry.flags.order;
      if ( append ) {
        context.chapters[append - 1].pages.push({ ...entry, sort: order, entry: true });
      } else {
        context.chapters.push(entry);
      }
    }

    for ( const chapter of context.chapters ) {
      chapter.pages.sort((lhs, rhs) => lhs.sort - rhs.sort);
      for ( const page of chapter.pages ) {
        if ( page.pages ) page.pages.sort((lhs, rhs) => lhs.sort - rhs.sort);
      }
    }

    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _renderFrame(options) {
    const frame = await super._renderFrame(options);
    frame.dataset.compendiumId = this.collection.metadata.id;
    return frame;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * Handle clicking a link to a journal entry or page.
   * @param {PointerEvent} event  The triggering click event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  async _onClickLink(event, target) {
    const entryId = target.closest("[data-entry-id]")?.dataset.entryId;
    if ( !entryId ) return;
    const entry = await this.collection.getDocument(entryId);
    entry?.sheet.render(true, {
      pageId: target.closest("[data-page-id]")?.dataset.pageId
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    let dragData;
    if ( ui.context ) ui.context.close({animate: false});
    dragData = this._getEntryDragData(event.target.dataset.documentId);
    if ( !dragData ) return;
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }
}

/**
 * Compendium that renders pages as a table of contents.
 * TODO: Remove when v12 support is dropped.
 */
class TableOfContentsCompendiumV12 extends (foundry.applications?.sidebar?.apps?.Compendium ?? Compendium) {
  /** @inheritDoc */
  static get defaultOptions() {
    return foundry.utils.mergeObject(super.defaultOptions, {
      classes: ["table-of-contents"],
      template: "systems/dnd5e/templates/journal/table-of-contents.hbs",
      width: 800,
      height: 950,
      resizable: true,
      contextMenuSelector: "[data-entry-id]",
      dragDrop: [{dragSelector: "[data-document-id]", dropSelector: "article"}]
    });
  }

  /* -------------------------------------------- */

  /**
   * Position of pages based on type.
   * @enum {number}
   */
  static TYPES = {
    chapter: 0,
    appendix: 100
  };

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options) {
    const context = await super.getData(options);
    const documents = await this.collection.getDocuments();

    context.chapters = [];
    const specialEntries = [];
    for ( const entry of documents ) {
      const flags = entry.flags?.dnd5e;
      if ( !flags ) continue;
      const type = flags.type ?? "chapter";

      if ( type === "header" ) {
        const page = entry.pages.contents[0];
        context.header = {
          title: flags.title ?? page?.name,
          content: page?.text.content
        };
        continue;
      }

      const data = {
        type, flags,
        id: entry.id,
        name: flags.title ?? entry.name,
        pages: Array.from(entry.pages).map(({ flags, id, name, sort }) => ({
          id, sort, flags,
          name: flags.dnd5e?.title ?? name,
          entryId: entry.id
        }))
      };

      if ( type === "special" ) {
        data.showPages = flags.showPages ?? !flags.append;
        specialEntries.push(data);
      } else {
        data.order = (this.constructor.TYPES[type] ?? 200) + (flags.position ?? 0);
        data.showPages = (flags.showPages !== false) && ((flags.showPages === true) || (type === "chapter"));
        context.chapters.push(data);
      }
    }

    context.chapters.sort((lhs, rhs) => lhs.order - rhs.order);
    for ( const entry of specialEntries ) {
      const append = entry.flags.append;
      const order = entry.flags.order;
      if ( append ) {
        context.chapters[append - 1].pages.push({ ...entry, sort: order, entry: true });
      } else {
        context.chapters.push(entry);
      }
    }

    for ( const chapter of context.chapters ) {
      chapter.pages.sort((lhs, rhs) => lhs.sort - rhs.sort);
      for ( const page of chapter.pages ) {
        if ( page.pages ) page.pages.sort((lhs, rhs) => lhs.sort - rhs.sort);
      }
    }

    return context;
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  activateListeners(html) {
    super.activateListeners(html);
    html.find("a").on("click", this._onClickLink.bind(this));
    this.element[0].dataset.compendiumId = this.collection.metadata.id;
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking a link to a journal entry or page.
   * @param {PointerEvent} event  The triggering click event.
   * @protected
   */
  async _onClickLink(event) {
    const entryId = event.currentTarget.closest("[data-entry-id]")?.dataset.entryId;
    if ( !entryId ) return;
    const entry = await this.collection.getDocument(entryId);
    entry?.sheet.render(true, {
      pageId: event.currentTarget.closest("[data-page-id]")?.dataset.pageId
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDragStart(event) {
    let dragData;
    if ( ui.context ) ui.context.close({animate: false});
    dragData = this._getEntryDragData(event.target.dataset.documentId);
    if ( !dragData ) return;
    event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }
}

var _module$h = /*#__PURE__*/Object.freeze({
  __proto__: null,
  JournalClassPageSheet: JournalClassPageSheet,
  JournalEditor: JournalEditor,
  JournalMapLocationPageSheet: JournalMapLocationPageSheet,
  JournalRulePageSheet: JournalRulePageSheet,
  JournalSheet5e: JournalSheet5e,
  JournalSpellListPageSheet: JournalSpellListPageSheet,
  TableOfContentsCompendium: TableOfContentsCompendium
});

var _module$g = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DocumentSheetV2Mixin: DocumentSheetV2Mixin
});

/**
 * Generate sidebar links.
 * @returns {HTMLUListElement}
 * @private
 */
function _generateLinks() {
  const links = document.createElement("ul");
  links.classList.add("unlist", "links");
  links.innerHTML = `
    <li>
      <a href="https://github.com/foundryvtt/dnd5e/releases/latest" target="_blank">
        ${game.i18n.localize("DND5E.Notes")}
      </a>
    </li>
    <li>
      <a href="https://github.com/foundryvtt/dnd5e/issues" target="_blank">${game.i18n.localize("DND5E.Issues")}</a>
    </li>
    <li>
      <a href="https://github.com/foundryvtt/dnd5e/wiki" target="_blank">${game.i18n.localize("DND5E.Wiki")}</a>
    </li>
    <li>
      <a href="https://discord.com/channels/170995199584108546/670336046164213761" target="_blank">
        ${game.i18n.localize("DND5E.Discord")}
      </a>
    </li>
  `;
  return links;
}

/* -------------------------------------------- */

/**
 * Render a custom entry for game details in the settings sidebar.
 * @param {HTMLElement} html  The settings sidebar HTML.
 */
function renderSettings(html) {
  const pip = html.querySelector(".info .system .notification-pip");
  html.querySelector(".info .system").remove();

  const section = document.createElement("section");
  section.classList.add("dnd5e2", "sidebar-info");
  section.innerHTML = `
    <h4 class="divider">${game.i18n.localize("WORLD.GameSystem")}</h4>
    <div class="dnd5e2 system-badge">
      <img src="systems/dnd5e/ui/official/dnd-badge-32.webp" data-tooltip="${dnd5e.title}" alt="${dnd5e.title}">
      <span class="system-info">${dnd5e.version}</span>
    </div>
  `;
  section.append(_generateLinks());
  if ( pip ) section.querySelector(".system-info").insertAdjacentElement("beforeend", pip);
  html.querySelector(".info").insertAdjacentElement("afterend", section);
}

/* -------------------------------------------- */

/**
 * Render a custom entry for game details in the settings sidebar.
 * @param {HTMLElement} html  The settings sidebar HTML.
 */
function renderSettingsLegacy(html) {
  const details = html.querySelector("#game-details");
  const pip = details.querySelector(".system-info .update");
  details.querySelector(".system").remove();

  const heading = document.createElement("div");
  heading.classList.add("dnd5e2", "sidebar-heading");
  heading.innerHTML = `<h2>${game.i18n.localize("WORLD.GameSystem")}</h2>`;
  heading.append(_generateLinks());
  details.insertAdjacentElement("afterend", heading);

  const badge = document.createElement("div");
  badge.classList.add("dnd5e2", "system-badge");
  badge.innerHTML = `
    <img src="systems/dnd5e/ui/official/dnd-badge-32.webp" data-tooltip="${dnd5e.title}" alt="${dnd5e.title}">
    <span class="system-info">${dnd5e.version}</span>
  `;
  if ( pip ) badge.querySelector(".system-info").insertAdjacentElement("beforeend", pip);
  heading.insertAdjacentElement("afterend", badge);
}

var sidebar = /*#__PURE__*/Object.freeze({
  __proto__: null,
  renderSettings: renderSettings,
  renderSettingsLegacy: renderSettingsLegacy
});

var _module$f = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BaseSettingsConfig: BaseSettingsConfig,
  BastionSettingsConfig: BastionSettingsConfig,
  CompendiumBrowserSettingsConfig: CompendiumBrowserSettingsConfig,
  ModuleArtSettingsConfig: ModuleArtSettingsConfig,
  VisibilitySettingsConfig: VisibilitySettingsConfig,
  sidebar: sidebar
});

var _module$e = /*#__PURE__*/Object.freeze({
  __proto__: null,
  CreatureTypeConfig: CreatureTypeConfig,
  MovementSensesConfig: MovementSensesConfig
});

const { OperatorTerm: OperatorTerm$2, RollTerm } = foundry.dice.terms;

/**
 * Parse the provided rolls, splitting parts based on damage types & properties, taking flavor into account.
 * @param {DamageRoll[]} rolls                   Evaluated damage rolls to aggregate.
 * @param {object} [options={}]
 * @param {boolean} [options.respectProperties]  Should damage properties also affect grouping?
 * @returns {DamageRoll[]}
 */
function aggregateDamageRolls(rolls, { respectProperties }={}) {
  const makeHash = (type, properties=[]) => [type, ...(respectProperties ? Array.from(properties).sort() : [])].join();

  // Split rolls into new sets of terms based on damage type & properties
  const types = new Map();
  for ( const roll of rolls ) {
    if ( !roll._evaluated ) throw new Error("Only evaluated rolls can be aggregated.");
    for ( const chunk of chunkTerms(roll.terms, roll.options.type) ) {
      const key = makeHash(chunk.type, roll.options.properties);
      if ( !types.has(key) ) types.set(key, { type: chunk.type, properties: new Set(), terms: [] });
      const data = types.get(key);
      data.terms.push(new OperatorTerm$2({ operator: chunk.negative ? "-" : "+" }), ...chunk.terms);
      if ( roll.options.properties ) data.properties = data.properties.union(new Set(roll.options.properties));
    }
  }

  // Create new damage rolls based on the aggregated terms
  const newRolls = [];
  for ( const type of types.values() ) {
    const roll = new CONFIG.Dice.DamageRoll();
    roll.terms = type.terms;
    roll._total = roll._evaluateTotal();
    roll._evaluated = true;
    roll.options = { type: type.type, properties: Array.from(type.properties) };
    roll.resetFormula();
    newRolls.push(roll);
  }

  return newRolls;
}

/* -------------------------------------------- */

/**
 * Split terms into groups based on operators. Addition & subtraction will split groups while multiplication and
 * division will keep groups together. These groups also contain information on contained types written in flavor
 * and whether they are negative.
 * @param {RollTerm[]} terms  Terms to chunk.
 * @param {string} type       Type specified in the roll as a whole.
 * @returns {{ terms: RollTerm[], negative: boolean, type: string }[]}
 */
function chunkTerms(terms, type) {
  const pushChunk = () => {
    currentChunk.type ??= type;
    chunks.push(currentChunk);
    currentChunk = null;
    negative = false;
  };
  const isValidType = t => ((t in CONFIG.DND5E.damageTypes) || (t in CONFIG.DND5E.healingTypes));
  const chunks = [];
  let currentChunk;
  let negative = false;

  for ( let term of terms ) {
    // Plus or minus operators split chunks
    if ( (term instanceof OperatorTerm$2) && ["+", "-"].includes(term.operator) ) {
      if ( currentChunk ) pushChunk();
      if ( term.operator === "-" ) negative = !negative;
      continue;
    }

    // All other terms get added to the current chunk
    term = RollTerm.fromData(foundry.utils.deepClone(term.toJSON()));
    currentChunk ??= { terms: [], negative, type: null };
    currentChunk.terms.push(term);
    const flavor = term.flavor?.toLowerCase().trim();
    if ( isValidType(flavor) ) {
      currentChunk.type ??= flavor;
      term.options.flavor = "";
    }
  }

  if ( currentChunk ) pushChunk();
  return chunks;
}

const { DiceTerm, FunctionTerm, NumericTerm, OperatorTerm: OperatorTerm$1, ParentheticalTerm, StringTerm } = foundry.dice.terms;

/**
 * Configuration data for the process of rolling a damage roll.
 *
 * @typedef {BasicRollProcessConfiguration} DamageRollProcessConfiguration
 * @property {DamageRollConfiguration[]} rolls         Configuration data for individual rolls.
 * @property {CriticalDamageConfiguration} [critical]  Critical configuration for all rolls.
 * @property {boolean} [isCritical]                    Treat each roll as a critical unless otherwise specified.
 * @property {number} [scaling=0]                      Scale increase above base damage.
 */

/**
 * Damage roll configuration data.
 *
 * @typedef {BasicRollConfiguration} DamageRollConfiguration
 * @property {DamageRollOptions} [options] - Options passed through to the roll.
 */

/**
 * Options that describe a damage roll.
 *
 * @typedef {BasicRollOptions} DamageRollOptions
 * @property {boolean} [isCritical]                    Should critical damage be calculated for this roll?
 * @property {CriticalDamageConfiguration} [critical]  Critical configuration for this roll.
 * @property {string[]} [properties]                   Physical properties of the source (e.g. magical, silvered).
 * @property {string} [type]                           Type of damage represented.
 * @property {string[]} [types]                        List of damage types selectable in the configuration app. If no
 *                                                     type is provided, then the first of these types will be used.
 */

/**
 * Critical effects configuration data.
 *
 * @typedef {object} CriticalDamageConfiguration
 * @property {boolean} [allow=true]       Should critical damage be allowed?
 * @property {number} [multiplier=2]      Amount by which to multiply critical damage.
 * @property {number} [bonusDice=0]       Additional dice added to first term when calculating critical damage.
 * @property {string} [bonusDamage]       Additional, unmodified, damage formula added when calculating a critical.
 * @property {boolean} [multiplyDice]     Should dice result be multiplied rather than number of dice rolled increased?
 * @property {boolean} [multiplyNumeric]  Should numeric terms be multiplied along side dice during criticals?
 * @property {string} [powerfulCritical]  Maximize result of extra dice added by critical, rather than rolling.
 */

/* -------------------------------------------- */

/**
 * A type of Roll specific to a damage (or healing) roll in the 5e system.
 * @param {string} formula                  The string formula to parse.
 * @param {object} data                     The data object against which to parse attributes within the formula.
 * @param {DamageRollOptions} [options={}]  Extra optional arguments which describe or modify the DamageRoll.
 */
class DamageRoll extends BasicRoll {
  constructor(formula, data, options) {
    super(formula, data, options);
    if ( !this.options.preprocessed ) this.preprocessFormula();
    if ( !this.options.configured ) this.configureDamage();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static DefaultConfigurationDialog = DamageRollConfigurationDialog;

  /* -------------------------------------------- */
  /*  Static Construction                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static fromConfig(config, process) {
    if ( process.critical ) {
      config = foundry.utils.deepClone(config);
      config.options ??= {};
      config.options.critical = foundry.utils.mergeObject(
        process.critical, config.options.critical ?? {}, { inplace: false }
      );
    }
    return super.fromConfig(config, process);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static async build(config={}, dialog={}, message={}) {
    config.critical ??= {};
    config.critical.multiplyNumeric ??= game.settings.get("dnd5e", "criticalDamageModifiers");
    config.critical.powerfulCritical ??= game.settings.get("dnd5e", "criticalDamageMaxDice");
    return super.build(config, dialog, message);
  }

  /* -------------------------------------------- */

  /** @override */
  static applyKeybindings(config, dialog, message) {
    const keys = {
      default: areKeysPressed(config.event, "skipDialogNormal"),
      normal: areKeysPressed(config.event, "skipDialogDisadvantage"),
      critical: areKeysPressed(config.event, "skipDialogAdvantage")
    };

    // Should the roll configuration dialog be displayed?
    dialog.configure ??= Object.values(keys).every(k => !k);

    // Determine critical mode
    for ( const roll of config.rolls ) {
      roll.options ??= {};
      roll.options.isCritical ??= (config.isCritical || keys.critical) && !keys.normal;
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Is this damage critical.
   * @type {boolean}
   */
  get isCritical() {
    return this.options.isCritical === true;
  }

  /* -------------------------------------------- */
  /*  Roll Configuration                          */
  /* -------------------------------------------- */

  /**
   * Perform any term-merging required to ensure that criticals can be calculated successfully.
   * @protected
   */
  preprocessFormula() {
    for ( let [i, term] of this.terms.entries() ) {
      const nextTerm = this.terms[i + 1];
      const prevTerm = this.terms[i - 1];

      // Convert shorthand dX terms to 1dX preemptively to allow them to be appropriately doubled for criticals
      if ( (term instanceof StringTerm) && /^d\d+/.test(term.term) && !(prevTerm instanceof ParentheticalTerm) ) {
        const formula = `1${term.term}`;
        const newTerm = new Roll(formula).terms[0];
        this.terms.splice(i, 1, newTerm);
        term = newTerm;
      }

      // Merge parenthetical terms that follow string terms to build a dice term (to allow criticals)
      else if ( (term instanceof ParentheticalTerm) && (prevTerm instanceof StringTerm)
        && prevTerm.term.match(/^[0-9]*d$/)) {
        if ( term.isDeterministic ) {
          let newFormula = `${prevTerm.term}${term.evaluate().total}`;
          let deleteCount = 2;

          // Merge in any roll modifiers
          if ( nextTerm instanceof StringTerm ) {
            newFormula += nextTerm.term;
            deleteCount += 1;
          }

          const newTerm = (new Roll(newFormula)).terms[0];
          this.terms.splice(i - 1, deleteCount, newTerm);
          term = newTerm;
        }
      }

      // Merge any parenthetical terms followed by string terms
      else if ( (term instanceof ParentheticalTerm || term instanceof FunctionTerm) && (nextTerm instanceof StringTerm)
        && nextTerm.term.match(/^d[0-9]*$/)) {
        if ( term.isDeterministic ) {
          const newFormula = `${term.evaluate().total}${nextTerm.term}`;
          const newTerm = (new Roll(newFormula)).terms[0];
          this.terms.splice(i, 2, newTerm);
          term = newTerm;
        }
      }
    }

    // Re-compile the underlying formula
    this.resetFormula();

    // Mark configuration as complete
    this.options.preprocessed = true;
  }

  /* -------------------------------------------- */

  /**
   * Apply optional modifiers which customize the behavior of the d20term.
   * @param {object} [options={}]
   * @param {CriticalDamageConfiguration} [options.critical={}]  Critical configuration to take into account, will be
   *                                                             superseded by the roll's configuration.
   * @protected
   */
  configureDamage({ critical={} }={}) {
    critical = foundry.utils.mergeObject(critical, this.options.critical ?? {}, { inplace: false });

    const flatBonus = new Map();
    for ( let [i, term] of this.terms.entries() ) {
      // Multiply dice terms
      if ( term instanceof DiceTerm ) {
        if ( term._number instanceof Roll ) {
          // Complex number term.
          if ( !term._number.isDeterministic ) continue;
          if ( !term._number._evaluated ) term._number.evaluateSync();
        }
        term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
        term.number = term.options.baseNumber;
        if ( this.isCritical ) {
          let cm = critical.multiplier ?? 2;

          // Powerful critical - maximize damage and reduce the multiplier by 1
          if ( critical.powerfulCritical ) {
            const bonus = Roll.create(term.formula).evaluateSync({ maximize: true }).total;
            if ( bonus > 0 ) {
              const flavor = term.flavor?.toLowerCase().trim() ?? game.i18n.localize("DND5E.PowerfulCritical");
              flatBonus.set(flavor, (flatBonus.get(flavor) ?? 0) + bonus);
            }
            cm = Math.max(1, cm-1);
          }

          // Alter the damage term
          let cb = (critical.bonusDice && (i === 0)) ? critical.bonusDice : 0;
          term.alter(cm, cb);
          term.options.critical = true;
        }
      }

      // Multiply numeric terms
      else if ( critical.multiplyNumeric && (term instanceof NumericTerm) ) {
        term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back
        term.number = term.options.baseNumber;
        if ( this.isCritical ) {
          term.number *= (critical.multiplier ?? 2);
          term.options.critical = true;
        }
      }
    }

    // Add powerful critical bonus
    if ( critical.powerfulCritical && flatBonus.size ) {
      for ( const [type, number] of flatBonus.entries() ) {
        this.terms.push(new OperatorTerm$1({operator: "+"}));
        this.terms.push(new NumericTerm({number, options: {flavor: type}}));
      }
    }

    // Add extra critical damage term
    if ( this.isCritical && critical.bonusDamage ) {
      const extra = new Roll(critical.bonusDamage, this.data);
      if ( !(extra.terms[0] instanceof OperatorTerm$1) ) this.terms.push(new OperatorTerm$1({operator: "+"}));
      this.terms.push(...extra.terms);
    }

    // Re-compile the underlying formula
    this.resetFormula();

    // Mark configuration as complete
    this.options.configured = true;
  }

  /* -------------------------------------------- */
  /*  Configuration Dialog                        */
  /* -------------------------------------------- */

  /**
   * Create a Dialog prompt used to configure evaluation of an existing D20Roll instance.
   * @param {object} data                     Dialog configuration data
   * @param {string} [data.title]               The title of the shown dialog window
   * @param {number} [data.defaultRollMode]     The roll mode that the roll mode select element should default to
   * @param {string} [data.defaultCritical]     Should critical be selected as default
   * @param {string} [data.template]            A custom path to an HTML template to use instead of the default
   * @param {boolean} [data.allowCritical=true] Allow critical hit to be chosen as a possible damage mode
   * @param {object} options                  Additional Dialog customization options
   * @returns {Promise<D20Roll|null>}         A resulting D20Roll object constructed with the dialog, or null if the
   *                                          dialog was closed
   */
  async configureDialog(data={}, options={}) {
    const rolls = await this.constructor.configureDialog([this], data, options);
    return rolls[0] ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Create a Dialog prompt used to configure evaluation of one or more damage rolls.
   * @param {DamageRoll[]} rolls                Damage rolls to configure.
   * @param {object} [data={}]                  Dialog configuration data
   * @param {string} [data.title]               The title of the shown dialog window
   * @param {number} [data.defaultRollMode]     The roll mode that the roll mode select element should default to
   * @param {string} [data.defaultCritical]     Should critical be selected as default
   * @param {string} [data.template]            A custom path to an HTML template to use instead of the default
   * @param {boolean} [data.allowCritical=true] Allow critical hit to be chosen as a possible damage mode
   * @param {object} options                    Additional Dialog customization options
   * @returns {Promise<D20Roll|null>}           A resulting D20Roll object constructed with the dialog, or null if the
   *                                            dialog was closed
   */
  static async configureDialog(rolls, {
    title, defaultRollMode, defaultCritical=false, template, allowCritical=true}={}, options={}) {
    foundry.utils.logCompatibilityWarning(
      "The `configureDialog` on DamageRoll has been deprecated and is now handled through `DamageRoll.build`.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4" }
    );
    const DialogClass = this.DefaultConfigurationDialog;
    return await DialogClass.configure(
      { critical: { allow: allowCritical }, rolls: rolls.map(r => ({ parts: [r.formula], options: r.options })) },
      { options: { title } },
      { rollMode: defaultRollMode }
    );
  }
}

class ChatMessage5e extends ChatMessage {

  /**
   * HTML tag names for chat trays that can open and close.
   * @type {string[]}
   */
  static TRAY_TYPES = ["damage-application", "effect-application"];

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The currently highlighted token for attack roll evaluation.
   * @type {Token5e|null}
   */
  _highlighted = null;

  /* -------------------------------------------- */

  /**
   * Should the apply damage options appear?
   * @type {boolean}
   */
  get canApplyDamage() {
    const type = this.flags.dnd5e?.roll?.type;
    if ( type && (type !== "damage") ) return false;
    return this.isRoll && this.isContentVisible && !!canvas.tokens?.controlled.length;
  }

  /* -------------------------------------------- */

  /**
   * Should the select targets options appear?
   * @type {boolean}
   */
  get canSelectTargets() {
    if ( this.flags.dnd5e?.roll?.type !== "attack" ) return false;
    return this.isRoll && this.isContentVisible;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get isRoll() {
    if ( this.system?.isRoll !== undefined ) return this.system.isRoll;
    return super.isRoll && !this.flags.dnd5e?.rest;
  }

  /* -------------------------------------------- */

  /**
   * Should roll DCs and other challenge details be displayed on this card?
   * @type {boolean}
   */
  get shouldDisplayChallenge() {
    if ( game.user.isGM || (this.author === game.user) ) return true;
    switch ( game.settings.get("dnd5e", "challengeVisibility") ) {
      case "all": return true;
      case "player": return !this.author.isGM;
      default: return false;
    }
  }

  /* -------------------------------------------- */

  /**
   * Store the state of any trays in the message.
   * @type {Map<string, boolean>}
   * @protected
   */
  _trayStates;

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static migrateData(source) {
    source = super.migrateData(source);
    if ( foundry.utils.hasProperty(source, "flags.dnd5e.itemData") ) {
      foundry.utils.setProperty(source, "flags.dnd5e.item.data", source.flags.dnd5e.itemData);
      delete source.flags.dnd5e.itemData;
    }
    if ( foundry.utils.hasProperty(source, "flags.dnd5e.use") ) {
      const use = source.flags.dnd5e.use;
      foundry.utils.setProperty(source, "flags.dnd5e.messageType", "usage");
      if ( use.type ) foundry.utils.setProperty(source, "flags.dnd5e.item.type", use.type);
      if ( use.itemId ) foundry.utils.setProperty(source, "flags.dnd5e.item.id", use.itemId);
      if ( use.itemUuid ) foundry.utils.setProperty(source, "flags.dnd5e.item.uuid", use.itemUuid);
    }
    return source;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareData() {
    super.prepareData();
    this._shimFlags();
    if ( !this.flags.dnd5e?.item?.data && this.flags.dnd5e?.item?.id ) {
      const itemData = this.getFlag("dnd5e", "use.consumed.deleted")?.find(i => i._id === this.flags.dnd5e.item.id);
      if ( itemData ) Object.defineProperty(this.flags.dnd5e.item, "data", { value: itemData });
    }
    dnd5e.registry.messages.track(this);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getHTML(options={}) {
    const html = await super.getHTML(options);
    const element = (html instanceof HTMLElement) ? html : html[0];

    if ( foundry.utils.getType(this.system?.getHTML) === "function" ) {
      await this.system.getHTML(element, options);
      return html;
    }

    this._displayChatActionButtons(html);
    this._highlightCriticalSuccessFailure(html);
    if ( game.settings.get("dnd5e", "autoCollapseItemCards") ) {
      html.find(".description.collapsible").each((i, el) => el.classList.add("collapsed"));
    }

    this._enrichChatCard(element);
    this._collapseTrays(element);
    this._activateActivityListeners(element);
    dnd5e.bastion._activateChatListeners(this, element);

    /**
     * A hook event that fires after dnd5e-specific chat message modifications have completed.
     * @function dnd5e.renderChatMessage
     * @memberof hookEvents
     * @param {ChatMessage5e} message  Chat message being rendered.
     * @param {HTMLElement} html       HTML contents of the message.
     */
    Hooks.callAll("dnd5e.renderChatMessage", this, element);

    return html;
  }

  /* -------------------------------------------- */

  /**
   * Handle collapsing or expanding trays depending on user settings.
   * @param {HTMLElement} html  Rendered contents of the message.
   */
  _collapseTrays(html) {
    let collapse;
    switch ( game.settings.get("dnd5e", "autoCollapseChatTrays") ) {
      case "always": collapse = true; break;
      case "never": collapse = false; break;
      // Collapse chat message trays older than 5 minutes
      case "older": collapse = this.timestamp < Date.now() - (5 * 60 * 1000); break;
    }
    for ( const tray of html.querySelectorAll(".card-tray") ) {
      tray.classList.toggle("collapsed", this._trayStates?.get(tray.className.replace(" collapsed", "")) ?? collapse);
    }
    for ( const element of html.querySelectorAll(this.constructor.TRAY_TYPES.join(", ")) ) {
      element.toggleAttribute("open", this._trayStates?.get(element.tagName) ?? !collapse);
    }
  }

  /* -------------------------------------------- */

  /**
   * Optionally hide the display of chat card action buttons which cannot be performed by the user
   * @param {jQuery} html     Rendered contents of the message.
   * @protected
   */
  _displayChatActionButtons(html) {
    const chatCard = html.find(".dnd5e.chat-card, .dnd5e2.chat-card");
    if ( chatCard.length > 0 ) {
      const flavor = html.find(".flavor-text");
      if ( flavor.text() === html.find(".item-name").text() ) flavor.remove();

      if ( this.shouldDisplayChallenge ) chatCard[0].dataset.displayChallenge = "";

      const actor = game.actors.get(this.speaker.actor);
      const isCreator = game.user.isGM || actor?.isOwner || (this.author.id === game.user.id);
      for ( const button of html[0].querySelectorAll(".card-buttons button") ) {
        if ( button.dataset.visibility === "all" ) continue;

        // GM buttons should only be visible to GMs, otherwise button should only be visible to message's creator
        if ( ((button.dataset.visibility === "gm") && !game.user.isGM) || !isCreator
          || this.getAssociatedActivity()?.shouldHideChatButton(button, this) ) button.hidden = true;
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * Highlight critical success or failure on d20 rolls.
   * @param {jQuery} html     Rendered contents of the message.
   * @protected
   */
  _highlightCriticalSuccessFailure(html) {
    if ( !this.isContentVisible || !this.rolls.length ) return;
    const originatingMessage = this.getOriginatingMessage();
    const displayChallenge = originatingMessage?.shouldDisplayChallenge;
    const displayAttackResult = game.user.isGM || (game.settings.get("dnd5e", "attackRollVisibility") !== "none");
    const forceSuccess = this.flags.dnd5e?.roll?.forceSuccess === true;

    /**
     * Create an icon to indicate success or failure.
     * @param {string} cls  The icon class.
     * @returns {HTMLElement}
     */
    function makeIcon(cls) {
      const icon = document.createElement("i");
      icon.classList.add("fas", cls);
      icon.setAttribute("inert", "");
      return icon;
    }

    // Highlight rolls where the first part is a d20 roll
    for ( let [index, d20Roll] of this.rolls.entries() ) {

      const d0 = d20Roll.dice[0];
      if ( (d0?.faces !== 20) || (d0?.values.length !== 1) ) continue;

      d20Roll = dnd5e.dice.D20Roll.fromRoll(d20Roll);
      const d = d20Roll.dice[0];

      const isModifiedRoll = ("success" in d.results[0]) || d.options.marginSuccess || d.options.marginFailure;
      if ( isModifiedRoll ) continue;

      // Highlight successes and failures
      const total = html.find(".dice-total")[index];
      if ( !total ) continue;
      // Only attack rolls and death saves can crit or fumble.
      const canCrit = ["attack", "death"].includes(this.getFlag("dnd5e", "roll.type"));
      const isAttack = this.getFlag("dnd5e", "roll.type") === "attack";
      const showResult = isAttack ? displayAttackResult : displayChallenge;
      if ( d.options.target && showResult ) {
        if ( d20Roll.isSuccess || forceSuccess ) total.classList.add("success");
        else total.classList.add("failure");
      }
      if ( canCrit && d20Roll.isCritical ) total.classList.add("critical");
      if ( canCrit && d20Roll.isFumble && !forceSuccess ) total.classList.add("fumble");

      const icons = document.createElement("div");
      icons.classList.add("icons");
      if ( total.classList.contains("critical") ) icons.append(makeIcon("fa-check"), makeIcon("fa-check"));
      else if ( total.classList.contains("fumble") ) icons.append(makeIcon("fa-xmark"), makeIcon("fa-xmark"));
      else if ( total.classList.contains("success") ) icons.append(makeIcon("fa-check"));
      else if ( total.classList.contains("failure") ) icons.append(makeIcon("fa-xmark"));
      if ( icons.children.length ) total.append(icons);
    }
  }

  /* -------------------------------------------- */

  /**
   * Augment the chat card markup for additional styling.
   * @param {HTMLElement} html  The chat card markup.
   * @protected
   */
  _enrichChatCard(html) {
    // Header matter
    const actor = this.getAssociatedActor();

    let img;
    let nameText;
    if ( this.isContentVisible ) {
      img = actor?.img ?? this.author.avatar;
      nameText = this.alias;
    } else {
      img = this.author.avatar;
      nameText = this.author.name;
    }

    const avatar = document.createElement("a");
    avatar.classList.add("avatar");
    if ( actor ) avatar.dataset.uuid = actor.uuid;
    const avatarImg = document.createElement("img");
    Object.assign(avatarImg, { src: img, alt: nameText });
    avatar.append(avatarImg);

    const name = document.createElement("span");
    name.classList.add("name-stacked");
    const title = document.createElement("span");
    title.classList.add("title");
    title.append(nameText);
    name.append(title);

    const subtitle = document.createElement("span");
    subtitle.classList.add("subtitle");
    if ( this.whisper.length ) subtitle.innerText = html.querySelector(".whisper-to")?.innerText ?? "";
    if ( (nameText !== this.author?.name) && !subtitle.innerText.length ) subtitle.innerText = this.author?.name ?? "";

    name.appendChild(subtitle);

    const sender = html.querySelector(".message-sender");
    sender?.replaceChildren(avatar, name);
    html.querySelector(".whisper-to")?.remove();

    // Context menu
    const metadata = html.querySelector(".message-metadata");
    const deleteButton = metadata.querySelector(".message-delete");
    if ( !game.user.isGM ) deleteButton?.remove();
    const anchor = document.createElement("a");
    anchor.setAttribute("aria-label", game.i18n.localize("DND5E.AdditionalControls"));
    anchor.classList.add("chat-control");
    anchor.dataset.contextMenu = "";
    anchor.innerHTML = '<i class="fas fa-ellipsis-vertical fa-fw"></i>';
    metadata.appendChild(anchor);

    // SVG icons
    html.querySelectorAll("i.dnd5e-icon").forEach(el => {
      const icon = document.createElement("dnd5e-icon");
      icon.src = el.dataset.src;
      el.replaceWith(icon);
    });

    // Enriched roll flavor
    const roll = this.getFlag("dnd5e", "roll");
    const item = this.getAssociatedItem();
    const activity = this.getAssociatedActivity();
    if ( this.isContentVisible && item && roll ) {
      const isCritical = (roll.type === "damage") && this.rolls[0]?.isCritical;
      const subtitle = roll.type === "damage"
        ? isCritical
          ? game.i18n.localize("DND5E.CriticalHit")
          : activity?.damageFlavor ?? game.i18n.localize("DND5E.DamageRoll")
        : roll.type === "attack"
          ? (activity?.getActionLabel(roll.attackMode) ?? "")
          : (item.system.type?.label ?? game.i18n.localize(CONFIG.Item.typeLabels[item.type]));
      const flavor = document.createElement("div");
      flavor.classList.add("dnd5e2", "chat-card");
      flavor.innerHTML = `
        <section class="card-header description ${isCritical ? "critical" : ""}">
          <header class="summary">
            <div class="name-stacked">
              <span class="subtitle">${subtitle}</span>
            </div>
          </header>
        </section>
      `;
      const icon = document.createElement("img");
      Object.assign(icon, { className: "gold-icon", src: item.img, alt: item.name });
      flavor.querySelector("header").insertAdjacentElement("afterbegin", icon);
      const title = document.createElement("span");
      title.classList.add("title");
      title.append(item.name);
      flavor.querySelector(".name-stacked").insertAdjacentElement("afterbegin", title);
      html.querySelector(".message-header .flavor-text").remove();
      html.querySelector(".message-content").insertAdjacentElement("afterbegin", flavor);
    }

    // Attack targets
    this._enrichAttackTargets(html);

    // Dice rolls
    if ( this.isContentVisible ) {
      html.querySelectorAll(".dice-tooltip").forEach((el, i) => {
        if ( !(roll instanceof DamageRoll) && this.rolls[i] ) this._enrichRollTooltip(this.rolls[i], el);
      });
      this._enrichDamageTooltip(this.rolls.filter(r => r instanceof DamageRoll), html);
      this._enrichSaveTooltip(html);
      this._enrichEnchantmentTooltip(html);
      html.querySelectorAll(".dice-roll").forEach(el => el.addEventListener("click", this._onClickDiceRoll.bind(this)));
    } else {
      html.querySelectorAll(".dice-roll").forEach(el => el.classList.add("secret-roll"));
    }

    // Effects tray
    this._enrichUsageEffects(html);

    avatar.addEventListener("click", this._onTargetMouseDown.bind(this));
    avatar.addEventListener("pointerover", this._onTargetHoverIn.bind(this));
    avatar.addEventListener("pointerout", this._onTargetHoverOut.bind(this));
  }

  /* -------------------------------------------- */

  /**
   * Augment roll tooltips with some additional information and styling.
   * @param {Roll} roll            The roll instance.
   * @param {HTMLDivElement} html  The roll tooltip markup.
   */
  _enrichRollTooltip(roll, html) {
    const constant = Number(simplifyRollFormula(roll._formula, { deterministic: true }));
    if ( !constant ) return;
    const sign = constant < 0 ? "-" : "+";
    const part = document.createElement("section");
    part.classList.add("tooltip-part", "constant");
    part.innerHTML = `
      <div class="dice">
        <ol class="dice-rolls"></ol>
        <div class="total">
          <span class="value"><span class="sign">${sign}</span>${Math.abs(constant)}</span>
        </div>
      </div>
    `;
    html.appendChild(part);
  }

  /* -------------------------------------------- */

  /**
   * Augment attack cards with additional information.
   * @param {HTMLLIElement} html   The chat card.
   * @protected
   */
  _enrichAttackTargets(html) {
    const attackRoll = this.rolls[0];
    if ( !(attackRoll instanceof dnd5e.dice.D20Roll) ) return;

    const masteryConfig = CONFIG.DND5E.weaponMasteries[attackRoll.options.mastery];
    if ( masteryConfig ) {
      const p = document.createElement("p");
      p.classList.add("supplement");
      let mastery = masteryConfig.label;
      if ( masteryConfig.reference ) mastery = `
        <a class="content-link" draggable="true" data-link data-uuid="${masteryConfig.reference}"
           data-tooltip="${mastery}">${mastery}</a>
      `;
      p.innerHTML = `<strong>${game.i18n.format("DND5E.WEAPON.Mastery.Flavor")}</strong> ${mastery}`;
      (html.querySelector(".chat-card") ?? html.querySelector(".message-content"))?.appendChild(p);
    }

    const visibility = game.settings.get("dnd5e", "attackRollVisibility");
    const isVisible = game.user.isGM || (visibility !== "none");
    if ( !isVisible ) return;

    const targets = this.getFlag("dnd5e", "targets");
    if ( !targets?.length ) return;
    const tray = document.createElement("div");
    tray.classList.add("dnd5e2");
    tray.innerHTML = `
      <div class="card-tray targets-tray collapsible collapsed">
        <label class="roboto-upper">
          <i class="fas fa-bullseye" inert></i>
          <span>${game.i18n.localize("DND5E.TargetPl")}</span>
          <i class="fas fa-caret-down" inert></i>
        </label>
        <div class="collapsible-content">
          <ul class="dnd5e2 unlist evaluation wrapper"></ul>
        </div>
      </div>
    `;
    const evaluation = tray.querySelector("ul");
    const rows = targets.map(({ name, ac, uuid }) => {
      if ( !game.user.isGM && (visibility !== "all") ) ac = "";
      const isMiss = !attackRoll.isCritical && ((attackRoll.total < ac) || attackRoll.isFumble);
      const li = document.createElement("li");
      Object.assign(li.dataset, { uuid, miss: isMiss });
      li.className = `target ${isMiss ? "miss" : "hit"}`;
      li.innerHTML = `
        <i class="fas ${isMiss ? "fa-times" : "fa-check"}"></i>
        <div class="name"></div>
        ${(ac !== "") ? `
        <div class="ac">
          <i class="fas fa-shield-halved"></i>
          <span>${(ac === null) ? "&infin;" : ac}</span>
        </div>
        ` : ""}
      `;
      li.querySelector(".name").append(name);
      return li;
    }).sort((a, b) => {
      const missA = Boolean(a.dataset.miss);
      const missB = Boolean(b.dataset.miss);
      return missA === missB ? 0 : missA ? 1 : -1;
    });
    evaluation.append(...rows);
    evaluation.querySelectorAll("li.target").forEach(target => {
      target.addEventListener("click", this._onTargetMouseDown.bind(this));
      target.addEventListener("pointerover", this._onTargetHoverIn.bind(this));
      target.addEventListener("pointerout", this._onTargetHoverOut.bind(this));
    });
    html.querySelector(".message-content")?.appendChild(tray);
  }

  /* -------------------------------------------- */

  /**
   * Coalesce damage rolls into a single breakdown.
   * @param {DamageRoll[]} rolls  The damage rolls.
   * @param {HTMLElement} html    The chat card markup.
   * @protected
   */
  _enrichDamageTooltip(rolls, html) {
    if ( !rolls.length ) return;
    const aggregatedRolls = CONFIG.DND5E.aggregateDamageDisplay ? aggregateDamageRolls(rolls) : rolls;
    let { formula, total, breakdown } = aggregatedRolls.reduce((obj, r) => {
      obj.formula.push(CONFIG.DND5E.aggregateDamageDisplay ? r.formula : ` + ${r.formula}`);
      obj.total += r.total;
      obj.breakdown.push(this._simplifyDamageRoll(r));
      return obj;
    }, { formula: [], total: 0, breakdown: [] });
    formula = formula.join("").replace(/^ \+ /, "");
    html.querySelectorAll(".dice-roll").forEach(el => el.remove());
    const roll = document.createElement("div");
    roll.classList.add("dice-roll");

    const tooltipContents = breakdown.reduce((str, { type, total, constant, dice }) => {
      const config = CONFIG.DND5E.damageTypes[type] ?? CONFIG.DND5E.healingTypes[type];
      return `${str}
        <section class="tooltip-part">
          <div class="dice">
            <ol class="dice-rolls">
              ${dice.reduce((str, { result, classes }) => `
                ${str}<li class="roll ${classes}">${result}</li>
              `, "")}
              ${constant ? `
              <li class="constant"><span class="sign">${constant < 0 ? "-" : "+"}</span>${Math.abs(constant)}</li>
              ` : ""}
            </ol>
            <div class="total">
              ${config ? `<img src="${config.icon}" alt="${config.label}">` : ""}
              <span class="label">${config?.label ?? ""}</span>
              <span class="value">${total}</span>
            </div>
          </div>
        </section>
      `;
    }, "");

    roll.innerHTML = `
      <div class="dice-result">
        <div class="dice-formula">${formula}</div>
        <div class="dice-tooltip-collapser">
          <div class="dice-tooltip">
            ${tooltipContents}
          </div>
        </div>
        <h4 class="dice-total">${total}</h4>
      </div>
    `;
    html.querySelector(".message-content").appendChild(roll);

    const damageOnSave = this.getFlag("dnd5e", "roll.damageOnSave");
    if ( damageOnSave ) {
      const p = document.createElement("p");
      p.classList.add("supplement");
      p.innerHTML = `<strong>${game.i18n.format("DND5E.SAVE.OnSave")}</strong> ${
        game.i18n.localize(`DND5E.SAVE.FIELDS.damage.onSave.${damageOnSave.capitalize()}`)
      }`;
      html.querySelector(".chat-card, .message-content")?.appendChild(p);
    }

    if ( game.user.isGM ) {
      const damageApplication = document.createElement("damage-application");
      damageApplication.classList.add("dnd5e2");
      damageApplication.damages = aggregateDamageRolls(rolls, { respectProperties: true }).map(roll => ({
        value: roll.total,
        type: roll.options.type,
        properties: new Set(roll.options.properties ?? [])
      }));
      html.querySelector(".message-content").appendChild(damageApplication);
    }
  }

  /* -------------------------------------------- */

  /**
   * Simplify damage roll information for use by damage tooltip.
   * @param {DamageRoll} roll   The damage roll to simplify.
   * @returns {object}          The object holding simplified damage roll data.
   * @protected
   */
  _simplifyDamageRoll(roll) {
    const aggregate = { type: roll.options.type, total: roll.total, constant: 0, dice: [] };
    let hasMultiplication = false;
    for ( let i = roll.terms.length - 1; i >= 0; ) {
      const term = roll.terms[i--];
      if ( !(term instanceof foundry.dice.terms.NumericTerm) && !(term instanceof foundry.dice.terms.DiceTerm) ) {
        continue;
      }
      const value = term.total;
      if ( term instanceof foundry.dice.terms.DiceTerm ) aggregate.dice.push(...term.results.map(r => ({
        result: term.getResultLabel(r), classes: term.getResultCSS(r).filterJoin(" ")
      })));
      let multiplier = 1;
      let operator = roll.terms[i];
      while ( operator instanceof foundry.dice.terms.OperatorTerm ) {
        if ( !["+", "-"].includes(operator.operator) ) hasMultiplication = true;
        if ( operator.operator === "-" ) multiplier *= -1;
        operator = roll.terms[--i];
      }
      if ( term instanceof foundry.dice.terms.NumericTerm ) aggregate.constant += value * multiplier;
    }
    if ( hasMultiplication ) aggregate.constant = null;
    return aggregate;
  }

  /* -------------------------------------------- */

  /**
   * Display the enrichment application interface if necessary.
   * @param {HTMLLIElement} html   The chat card.
   * @protected
   */
  _enrichEnchantmentTooltip(html) {
    const enchantmentProfile = this.getFlag("dnd5e", "use.enchantmentProfile");
    if ( !enchantmentProfile ) return;

    // Ensure concentration is still being maintained
    const concentrationId = this.getFlag("dnd5e", "use.concentrationId");
    if ( concentrationId && !this.getAssociatedActor()?.effects.get(concentrationId) ) return;

    // Create the enchantment tray
    const enchantmentApplication = document.createElement("enchantment-application");
    enchantmentApplication.classList.add("dnd5e2");
    const afterElement = html.querySelector(".card-footer");
    if ( afterElement ) afterElement.insertAdjacentElement("beforebegin", enchantmentApplication);
    else html.querySelector(".chat-card")?.append(enchantmentApplication);
  }

  /* -------------------------------------------- */

  /**
   * Display option to resist a failed save using a legendary resistance.
   * @param {HTMLLIElement} html  The chat card.
   * @protected
   */
  _enrichSaveTooltip(html) {
    const actor = this.getAssociatedActor();
    const roll = this.getFlag("dnd5e", "roll");
    if ( (actor?.type !== "npc") || (roll?.type !== "save") || this.rolls.some(r => r.isSuccess) ) return;

    const content = document.createElement("div");
    content.classList.add("dnd5e2", "chat-card");

    // If message has the `forceSuccess` flag, mark it as resisted
    if ( roll.forceSuccess ) content.insertAdjacentHTML("beforeend", `
      <p class="supplement">
        <strong>${game.i18n.localize("DND5E.ROLL.Status")}</strong>
        ${game.i18n.localize("DND5E.LegendaryResistance.Resisted")}
      </p>
    `);

    // Otherwise if actor has legendary resistances remaining, display resist button
    else if ( actor.system.resources.legres.value && actor.isOwner ) {
      content.insertAdjacentHTML("beforeend", `
        <div class="card-buttons">
          <button type="button">
            <i class="fa-solid fa-dragon" inert></i>
            ${game.i18n.localize("DND5E.LegendaryResistance.Action.Resist")}
          </button>
        </div>
      `);
      const button = content.querySelector("button");
      button.addEventListener("click", () => actor.system.resistSave(this));
    }

    else return;

    html.querySelector(".message-content").append(content);
  }

  /* -------------------------------------------- */

  /**
   * Display the effects tray with effects the user can apply.
   * @param {HTMLLiElement} html  The chat card.
   * @protected
   */
  _enrichUsageEffects(html) {
    if ( this.getFlag("dnd5e", "messageType") !== "usage" ) return;
    const item = this.getAssociatedItem();
    const effects = this.getFlag("dnd5e", "use.effects")
      ?.map(id => item?.effects.get(id))
      .filter(e => e && (game.user.isGM || (e.transfer && (this.author.id === game.user.id))));
    if ( !effects?.length ) return;

    const effectApplication = document.createElement("effect-application");
    effectApplication.classList.add("dnd5e2");
    effectApplication.effects = effects;
    html.querySelector(".message-content").appendChild(effectApplication);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /**
   * This function is used to hook into the Chat Log context menu to add additional options to each message
   * These options make it easy to conveniently apply damage to controlled tokens based on the value of a Roll
   *
   * @param {HTMLElement} html    The Chat Message being rendered
   * @param {object[]} options    The Array of Context Menu options
   *
   * @returns {object[]}          The extended options Array including new context choices
   */
  static addChatMessageContextOptions(html, options) {
    const canApply = ([li]) => game.messages.get(li.dataset.messageId)?.canApplyDamage;
    const canTarget = ([li]) => game.messages.get(li.dataset.messageId)?.canSelectTargets;
    options.push(
      {
        name: game.i18n.localize("DND5E.ChatContextDamage"),
        icon: '<i class="fas fa-user-minus"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.data("messageId"))?.applyChatCardDamage(li, 1),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextHealing"),
        icon: '<i class="fas fa-user-plus"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.data("messageId"))?.applyChatCardDamage(li, -1),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextTempHP"),
        icon: '<i class="fas fa-user-clock"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.data("messageId"))?.applyChatCardTemp(li),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextDoubleDamage"),
        icon: '<i class="fas fa-user-injured"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.data("messageId"))?.applyChatCardDamage(li, 2),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextHalfDamage"),
        icon: '<i class="fas fa-user-shield"></i>',
        condition: canApply,
        callback: li => game.messages.get(li.data("messageId"))?.applyChatCardDamage(li, 0.5),
        group: "damage"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextSelectHit"),
        icon: '<i class="fas fa-bullseye"></i>',
        condition: canTarget,
        callback: ([li]) => game.messages.get(li.dataset.messageId)?.selectTargets(li, "hit"),
        group: "attack"
      },
      {
        name: game.i18n.localize("DND5E.ChatContextSelectMiss"),
        icon: '<i class="fas fa-bullseye"></i>',
        condition: canTarget,
        callback: ([li]) => game.messages.get(li.dataset.messageId)?.selectTargets(li, "miss"),
        group: "attack"
      }
    );
    return options;
  }

  /* -------------------------------------------- */

  /**
   * Add event listeners for chat messages created from activities.
   * @param {HTMLElement} html  The chat message HTML.
   */
  _activateActivityListeners(html) {
    this.getAssociatedActivity()?.activateChatListeners(this, html);
  }

  /* -------------------------------------------- */

  /**
   * Handle target selection and panning.
   * @param {Event} event   The triggering event.
   * @returns {Promise}     A promise that resolves once the canvas pan has completed.
   * @protected
   */
  async _onTargetMouseDown(event) {
    event.stopPropagation();
    const uuid = event.currentTarget.dataset.uuid;
    const actor = fromUuidSync(uuid);
    const token = actor?.token?.object ?? actor?.getActiveTokens()[0];
    if ( !token || !actor.testUserPermission(game.user, "OBSERVER")) return;
    const releaseOthers = !event.shiftKey;
    if ( token.controlled ) token.release();
    else {
      token.control({ releaseOthers });
      return canvas.animatePan(token.center);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering over a target in an attack roll message.
   * @param {Event} event     Initiating hover event.
   * @protected
   */
  _onTargetHoverIn(event) {
    const uuid = event.currentTarget.dataset.uuid;
    const actor = fromUuidSync(uuid);
    const token = actor?.token?.object ?? actor?.getActiveTokens()[0];
    if ( token && token.isVisible ) {
      if ( !token.controlled ) token._onHoverIn(event, { hoverOutOthers: true });
      this._highlighted = token;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering out of a target in an attack roll message.
   * @param {Event} event     Initiating hover event.
   * @protected
   */
  _onTargetHoverOut(event) {
    if ( this._highlighted ) this._highlighted._onHoverOut(event);
    this._highlighted = null;
  }

  /* -------------------------------------------- */

  /**
   * Apply rolled dice damage to the token or tokens which are currently controlled.
   * This allows for damage to be scaled by a multiplier to account for healing, critical hits, or resistance
   *
   * @param {HTMLElement} li      The chat entry which contains the roll data
   * @param {number} multiplier   A damage multiplier to apply to the rolled damage.
   * @returns {Promise}
   */
  applyChatCardDamage(li, multiplier) {
    const damages = aggregateDamageRolls(this.rolls, { respectProperties: true }).map(roll => ({
      value: roll.total,
      type: roll.options.type,
      properties: new Set(roll.options.properties ?? [])
    }));
    return Promise.all(canvas.tokens.controlled.map(t => {
      return t.actor?.applyDamage(damages, { multiplier });
    }));
  }

  /* -------------------------------------------- */

  /**
   * Select the hit or missed targets.
   * @param {HTMLElement} li    The chat entry which contains the roll data.
   * @param {string} type       The type of selection ('hit' or 'miss').
   */
  selectTargets(li, type) {
    if ( !canvas?.ready ) return;
    const lis = li.closest("[data-message-id]").querySelectorAll(`.evaluation li.target.${type}`);
    const uuids = new Set(Array.from(lis).map(n => n.dataset.uuid));
    canvas.tokens.releaseAll();
    uuids.forEach(uuid => {
      const actor = fromUuidSync(uuid);
      if ( !actor ) return;
      const tokens = actor.isToken ? [actor.token?.object] : actor.getActiveTokens();
      for ( const token of tokens ) {
        if ( token?.isVisible && actor.testUserPermission(game.user, "OWNER") ) {
          token.control({ releaseOthers: false });
        }
      }
    });
  }

  /* -------------------------------------------- */

  /**
   * Apply rolled dice as temporary hit points to the controlled token(s).
   * @param {HTMLElement} li  The chat entry which contains the roll data
   * @returns {Promise}
   */
  applyChatCardTemp(li) {
    const total = this.rolls.reduce((acc, roll) => acc + roll.total, 0);
    return Promise.all(canvas.tokens.controlled.map(t => {
      return t.actor?.applyTempHP(total);
    }));
  }

  /* -------------------------------------------- */

  /**
   * Handle dice roll expansion.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickDiceRoll(event) {
    event.stopPropagation();
    const target = event.currentTarget;
    target.classList.toggle("expanded");
  }

  /* -------------------------------------------- */

  /**
   * Handle rendering a chat popout.
   * @param {ChatPopout} app  The ChatPopout Application instance.
   * @param {jQuery} html     The rendered Application HTML.
   */
  static onRenderChatPopout(app, html) {
    html = html instanceof HTMLElement ? html : html[0];
    const close = html.querySelector(".header-button.close");
    if ( close ) {
      close.innerHTML = '<i class="fas fa-times"></i>';
      close.dataset.tooltip = game.i18n.localize("Close");
      close.setAttribute("aria-label", close.dataset.tooltip);
    }
    html.querySelector(".message-metadata [data-context-menu]")?.remove();
  }

  /* -------------------------------------------- */

  /**
   * Wait to apply appropriate element heights until after the chat log has completed its initial batch render.
   * @param {HTMLElement|jQuery} html
   */
  static onRenderChatLog(html) {
    if ( game.release.generation < 13 ) [html] = html;
    if ( game.user.isGM ) html.dataset.gmUser = "";
    if ( !game.settings.get("dnd5e", "autoCollapseItemCards") ) {
      requestAnimationFrame(() => {
        // FIXME: Allow time for transitions to complete. Adding a transitionend listener does not appear to work, so
        // the transition time is hard-coded for now.
        setTimeout(() => ui.chat.scrollBottom(), 250);
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Listen for shift key being pressed to show the chat message "delete" icon, or released (or focus lost) to hide it.
   */
  static activateListeners() {
    window.addEventListener("keydown", this.toggleModifiers, { passive: true });
    window.addEventListener("keyup", this.toggleModifiers, { passive: true });
    window.addEventListener("blur", () => this.toggleModifiers({ releaseAll: true }), { passive: true });
  }

  /* -------------------------------------------- */

  /**
   * Toggles attributes on the chatlog based on which modifier keys are being held.
   * @param {object} [options]
   * @param {boolean} [options.releaseAll=false]  Force all modifiers to be considered released.
   */
  static toggleModifiers({ releaseAll=false }={}) {
    document.querySelectorAll(".chat-sidebar > ol, #chat .chat-scroll > ol").forEach(chatlog => {
      for ( const key of Object.values(KeyboardManager.MODIFIER_KEYS) ) {
        if ( game.keyboard.isModifierActive(key) && !releaseAll ) chatlog.dataset[`modifier${key}`] = "";
        else delete chatlog.dataset[`modifier${key}`];
      }
    });
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    dnd5e.registry.messages.untrack(this);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Get the Activity that created this chat card.
   * @returns {Activity|void}
   */
  getAssociatedActivity() {
    const activity = fromUuidSync(this.getFlag("dnd5e", "activity.uuid"));
    if ( activity ) return activity;
    return this.getAssociatedItem()?.system.activities?.get(this.getFlag("dnd5e", "activity.id"));
  }

  /* -------------------------------------------- */

  /**
   * Get the Actor which is the author of a chat card.
   * @returns {Actor|void}
   */
  getAssociatedActor() {
    if ( this.speaker.scene && this.speaker.token ) {
      const scene = game.scenes.get(this.speaker.scene);
      const token = scene?.tokens.get(this.speaker.token);
      if ( token ) return token.actor;
    }
    return game.actors.get(this.speaker.actor);
  }

  /* -------------------------------------------- */

  /**
   * Get the item associated with this chat card.
   * @returns {Item5e|void}
   */
  getAssociatedItem() {
    const item = fromUuidSync(this.getFlag("dnd5e", "item.uuid"));
    if ( item ) return item;
    const actor = this.getAssociatedActor();
    if ( !actor ) return;
    const storedData = this.getFlag("dnd5e", "item.data") ?? this.getOriginatingMessage().getFlag("dnd5e", "item.data");
    if ( storedData ) return new Item.implementation(storedData, { parent: actor });
  }

  /* -------------------------------------------- */

  /**
   * Get a list of all chat messages containing rolls that originated from this message.
   * @param {string} [type]  Type of rolls to get. If empty, all roll types will be fetched.
   * @returns {ChatMessage5e[]}
   */
  getAssociatedRolls(type) {
    return dnd5e.registry.messages.get(this.id, type);
  }

  /* -------------------------------------------- */

  /**
   * Get the original chat message from which this message was created. If no originating message exists,
   * will return this message.
   * @type {ChatMessage5e}
   */
  getOriginatingMessage() {
    return game.messages.get(this.getFlag("dnd5e", "originatingMessage")) ?? this;
  }

  /* -------------------------------------------- */
  /*  Shims                                       */
  /* -------------------------------------------- */

  /**
   * Apply shims to maintain access to the old `use` and `itemData` flags.
   * @internal
   */
  _shimFlags() {
    const flags = foundry.utils.getProperty(this, "flags.dnd5e");
    if ( (flags?.messageType === "usage") && flags?.use ) {
      const message = "The item data in the `dnd5e.use` flag on `ChatMessage` is now `dnd5e.item.type`, "
      + "`dnd5e.item.id`, and `dnd5e.item.uuid`. Checking for usage can now be done using the "
      + "`dnd5e.messageType` flag.";
      Object.defineProperty(flags.use, "type", {
        get() {
          foundry.utils.logCompatibilityWarning(message, { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true });
          return flags.item?.type;
        },
        configurable: true,
        enumerable: false
      });
      Object.defineProperty(flags.use, "itemId", {
        get() {
          foundry.utils.logCompatibilityWarning(message, { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true });
          return flags.item?.id;
        },
        configurable: true,
        enumerable: false
      });
      Object.defineProperty(flags.use, "itemUuid", {
        get() {
          foundry.utils.logCompatibilityWarning(message, { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true });
          return flags.item?.uuid;
        },
        configurable: true,
        enumerable: false
      });
    }

    else if ( (flags?.messageType === "roll") && flags?.roll ) {
      const message = "The item data in the `dnd5e.roll` flag on `ChatMessage` is now `dnd5e.item.id` and "
      + "`dnd5e.item.uuid`.";
      Object.defineProperty(flags.roll, "itemId", {
        get() {
          foundry.utils.logCompatibilityWarning(message, { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true });
          return flags.item?.id;
        },
        configurable: true,
        enumerable: false
      });
      Object.defineProperty(flags.roll, "itemUuid", {
        get() {
          foundry.utils.logCompatibilityWarning(message, { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true });
          return flags.item?.uuid;
        },
        configurable: true,
        enumerable: false
      });
    }

    if ( flags?.item?.data ) Object.defineProperty(flags, "itemData", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `dnd5e.itemData` flag on `ChatMessage` is now `dnd5e.item.data`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
        );
        return this.item.data;
      },
      configurable: true,
      enumerable: false
    });
  }
}

/**
 * Custom implementation of the chat log to support saving tray states.
 */
class ChatLog5e extends (foundry.applications?.sidebar?.tabs?.ChatLog ?? ChatLog) {
  /** @inheritDoc */
  async updateMessage(message, notify=false) {
    const element = this.element instanceof HTMLElement ? this.element : this.element[0];
    const card = element.querySelector(`.message[data-message-id="${message.id}"]`);
    if ( card ) message._trayStates = new Map([
      ...Array.from(card.querySelectorAll(".card-tray"))
        .map(t => [t.className.replace(" collapsed", ""), t.classList.contains("collapsed")]),
      ...Array.from(card.querySelectorAll(ChatMessage5e.TRAY_TYPES.join(", "))).map(t => [t.tagName, t.open])
    ]);
    await super.updateMessage(message, notify);
  }
}

/**
 * Mixin method for v2-style dialogs.
 * @param {typeof Application} Base   Application class being extended.
 * @returns {class}
 * @mixin
 */
var dialogMixin = Base => class extends Base {
  /** @inheritDoc */
  async _renderOuter() {
    const html = await super._renderOuter();
    const header = html[0].querySelector(".window-header");
    header.querySelectorAll(".header-button").forEach(btn => {
      const label = btn.querySelector(":scope > i").nextSibling;
      btn.dataset.tooltip = label.textContent;
      btn.setAttribute("aria-label", label.textContent);
      label.remove();
    });
    return html;
  }
};

/**
 * Custom token configuration application for handling dynamic rings & resource labels.
 */
class TokenConfig5e extends (foundry.applications?.sheets?.TokenConfig ?? TokenConfig) {
  /** @inheritDoc */
  async _render(...args) {
    await super._render(...args);
    if ( !this.rendered ) return;
    this._prepareResourceLabels(this.element[0]);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.rendered ) return;
    this._prepareResourceLabels(this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getData(options={}) {
    const context = await super.getData(options);
    const doc = this.preview ?? this.document;
    context.scale = Math.abs(doc._source.texture.scaleX);
    this._addItemAttributes(context.barAttributes);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.scale = Math.abs(this.token._source.texture.scaleX);
    return context;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareResourcesTab() {
    const context = await super._prepareResourcesTab();
    this._addItemAttributes(context.barAttributes);
    return context;
  }

  /* -------------------------------------------- */

  /**
   * Adds charge based items as attributes for the current token.
   * @param {object} attributes The attribute groups to add the item entries to.
   * @protected
   */
  _addItemAttributes(attributes) {
    const actor = this.actor ?? this.object?.actor;
    const items = actor?.items.reduce((arr, i) => {
      if ( i.hasLimitedUses ) arr.push([i.getRelativeUUID(actor), i.name]);
      return arr;
    }, []) ?? [];
    if ( items.length ) {
      const group = game.i18n.localize("DND5E.ConsumeCharges");
      items.sort(([, a], [, b]) => a.localeCompare(b, game.i18n.lang));
      attributes.push(...items.map(([value, label]) => ({ group, value, label })));
    }
  }

  /* -------------------------------------------- */

  /**
   * Replace the attribute paths in token resources with human readable labels and sort them alphabetically.
   * @param {HTMLElement} html  The rendered markup.
   * @protected
   */
  _prepareResourceLabels(html) {
    const actor = this.actor ?? this.object?.actor;

    for ( const select of html.querySelectorAll('select:is(.bar-attribute, [name$=".attribute"])') ) {
      select.querySelectorAll("optgroup").forEach(group => {
        const options = Array.from(group.querySelectorAll("option"));

        // Localize attribute paths.
        options.forEach(option => {
          const label = getHumanReadableAttributeLabel(option.value, { actor });
          if ( label ) option.innerText = label;
        });

        // Sort options by localized label.
        options.sort((a, b) => a.innerText.localeCompare(b.innerText, game.i18n.lang));
        group.append(...options);
      });
    }
  }
}

class PrototypeTokenConfig5e extends (foundry.applications?.sheets?.PrototypeTokenConfig ?? class {}) {
  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    if ( !this.rendered ) return;
    TokenConfig5e.prototype._prepareResourceLabels.call(this, this.element);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareResourcesTab() {
    const context = await super._prepareResourcesTab();
    TokenConfig5e.prototype._addItemAttributes.call(this, context.barAttributes);
    return context;
  }
}

class BastionConfig extends BastionSettingsConfig {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "The `BastionConfig` application has been deprecated and replaced with `BastionSettingsConfig`.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4" }
    );
    super(...args);
  }
}

/**
 * An application for configuring which compendium packs contribute their content to the compendium browser.
 * @extends Application5e<CompendiumBrowserSourceConfiguration>
 */
class CompendiumBrowserSourceConfig extends CompendiumBrowserSettingsConfig {
  constructor(...args) {
    foundry.utils.logCompatibilityWarning(
      "The `CompendiumBrowserSourceConfig` application has been deprecated and replaced with `CompendiumBrowserSettingsConfig`.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4" }
    );
    super(...args);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Collate sources for inclusion in the compendium browser.
   * @returns {Set<string>}  The set of packs that should be included in the compendium browser.
   */
  static collateSources() {
    foundry.utils.logCompatibilityWarning(
      "The `CompendiumBrowserSourceConfig` application has been deprecated and replaced with `CompendiumBrowserSettingsConfig`.",
      { since: "DnD5e 4.2", until: "DnD5e 4.4" }
    );
    return super.collateSources();
  }
}

var applications = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Accordion: Accordion,
  Award: Award,
  BastionConfig: BastionConfig,
  ChatLog5e: ChatLog5e,
  CompendiumBrowser: CompendiumBrowser,
  CompendiumBrowserSourceConfig: CompendiumBrowserSourceConfig,
  ContextMenu5e: ContextMenu5e,
  CurrencyManager: CurrencyManager,
  DialogMixin: dialogMixin,
  PropertyAttribution: PropertyAttribution,
  PrototypeTokenConfig5e: PrototypeTokenConfig5e,
  SheetConfig: SheetConfig5e,
  SourceConfig: SourceConfig,
  Tabs5e: Tabs5e,
  TokenConfig5e: TokenConfig5e,
  activity: _module$p,
  actor: _module$o,
  advancement: _module$n,
  api: _module$m,
  combat: _module$l,
  components: _module$k,
  dice: _module$j,
  fields: fields,
  item: _module$i,
  journal: _module$h,
  mixins: _module$g,
  settings: _module$f,
  shared: _module$e
});

/**
 * The detection mode for Blindsight.
 */
class DetectionModeBlindsight extends (foundry.canvas?.perception?.DetectionMode ?? DetectionMode) {
  constructor() {
    super({
      id: "blindsight",
      label: "DND5E.SenseBlindsight",
      type: (foundry.canvas?.perception?.DetectionMode ?? DetectionMode).DETECTION_TYPES.OTHER,
      walls: true,
      angle: false
    });
  }

  /** @override */
  static getDetectionFilter() {
    return this._detectionFilter ??= OutlineOverlayFilter.create({
      outlineColor: [1, 1, 1, 1],
      knockout: true,
      wave: true
    });
  }

  /** @override */
  _canDetect(visionSource, target) {
    if ( visionSource.object.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    if ( target instanceof Token ) {
      if ( target.document.hasStatusEffect(CONFIG.specialStatusEffects.BURROW) ) return false;
    }
    return true;
  }

  /** @override */
  _testLOS(visionSource, mode, target, test) {
    return !CONFIG.Canvas.polygonBackends.sight.testCollision(
      { x: visionSource.x, y: visionSource.y },
      test.point,
      {
        type: "sight",
        mode: "any",
        source: visionSource,
        // Blindsight is restricted by total cover and therefore cannot see
        // through windows. So we do not want blindsight to see through
        // a window as we get close to it. That's why we ignore thresholds.
        // We make the assumption that all windows are configured as threshold
        // walls. A move-based visibility check would also be an option to check
        // for total cover, but this would have the undesirable side effect that
        // blindsight wouldn't work through fences, portcullises, etc.
        useThreshold: false
      }
    );
  }
}

CONFIG.Canvas.detectionModes.blindsight = new DetectionModeBlindsight();

var _module$d = /*#__PURE__*/Object.freeze({
  __proto__: null,
  DetectionModeBlindsight: DetectionModeBlindsight
});

/**
 * Add support for drawing custom control icons based on linked journal page type.
 */
class Note5e extends (foundry.canvas?.placeables?.Note ?? Note) {
  /** @inheritDoc */
  _drawControlIcon() {
    const tint = Color.from(this.document.texture.tint || null);
    const systemIcon = this.page?.system?.getControlIcon?.({ size: this.document.iconSize, tint });
    if ( !systemIcon ) return super._drawControlIcon();
    systemIcon.x -= (this.document.iconSize / 2);
    systemIcon.y -= (this.document.iconSize / 2);
    return systemIcon;
  }
}

/**
 * Extend the base Token class to implement additional system-specific logic.
 */
class Token5e extends (foundry.canvas?.placeables?.Token ?? Token) {

  /**
   * Update the token ring when this token is targeted.
   * @param {User5e} user         The user whose targeting has changed.
   * @param {Token5e} token       The token that was targeted.
   * @param {boolean} targeted    Is the token targeted or not?
   */
  static onTargetToken(user, token, targeted) {
    if ( !targeted ) return;
    if ( !token.hasDynamicRing ) return;
    const color = Color.from(user.color);
    token.ring.flashColor(color, { duration: 500, easing: token.ring.constructor.easeTwoPeaks });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _drawBar(number, bar, data) {
    if ( data.attribute === "attributes.hp" ) return this._drawHPBar(number, bar, data);
    return super._drawBar(number, bar, data);
  }

  /* -------------------------------------------- */

  /**
   * Specialized drawing function for HP bars.
   * @param {number} number      The Bar number
   * @param {PIXI.Graphics} bar  The Bar container
   * @param {object} data        Resource data for this bar
   * @private
   */
  _drawHPBar(number, bar, data) {

    // Extract health data
    let {value, max, effectiveMax, temp, tempmax} = this.document.actor.system.attributes.hp;
    temp = Number(temp || 0);
    tempmax = Number(tempmax || 0);

    // Differentiate between effective maximum and displayed maximum
    effectiveMax = Math.max(0, effectiveMax);
    let displayMax = max + (tempmax > 0 ? tempmax : 0);

    // Allocate percentages of the total
    const tempPct = Math.clamp(temp, 0, displayMax) / displayMax;
    const colorPct = Math.clamp(value, 0, effectiveMax) / displayMax;
    const hpColor = dnd5e.documents.Actor5e.getHPColor(value, effectiveMax);

    // Determine colors to use
    const blk = 0x000000;
    const c = CONFIG.DND5E.tokenHPColors;

    // Determine the container size (logic borrowed from core)
    const w = this.w;
    let h = Math.max((canvas.dimensions.size / 12), 8);
    if ( this.document.height >= 2 ) h *= 1.6;
    const bs = Math.clamp(h / 8, 1, 2);
    const bs1 = bs+1;

    // Overall bar container
    bar.clear();
    bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, w, h, 3);

    // Temporary maximum HP
    if (tempmax > 0) {
      const pct = max / effectiveMax;
      bar.beginFill(c.tempmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
    }

    // Maximum HP penalty
    else if (tempmax < 0) {
      const pct = (max + tempmax) / max;
      bar.beginFill(c.negmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2);
    }

    // Health bar
    bar.beginFill(hpColor, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, colorPct*w, h, 2);

    // Temporary hit points
    if ( temp > 0 ) {
      bar.beginFill(c.temp, 1.0).lineStyle(0).drawRoundedRect(bs1, bs1, (tempPct*w)-(2*bs1), h-(2*bs1), 1);
    }

    // Set position
    let posY = (number === 0) ? (this.h - h) : 0;
    bar.position.set(0, posY);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onApplyStatusEffect(statusId, active) {
    const applicableEffects = [CONFIG.specialStatusEffects.DEFEATED, CONFIG.specialStatusEffects.INVISIBLE];
    if ( applicableEffects.includes(statusId) && this.hasDynamicRing ) {
      this.renderFlags.set({refreshRingVisuals: true});
    }
    super._onApplyStatusEffect(statusId, active);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _configureFilterEffect(statusId, active) {
    if ( (statusId === CONFIG.specialStatusEffects.INVISIBLE) && this.hasDynamicRing ) active = false;
    return super._configureFilterEffect(statusId, active);
  }

  /* -------------------------------------------- */

  /** @override */
  getRingColors() {
    return this.document.getRingColors();
  }

  /* -------------------------------------------- */

  /** @override */
  getRingEffects() {
    return this.document.getRingEffects();
  }
}

var canvas$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityTemplate: AbilityTemplate,
  MapLocationControlIcon: MapLocationControlIcon,
  Note5e: Note5e,
  Token5e: Token5e,
  TokenPlacement: TokenPlacement,
  detectionModes: _module$d
});

/**
 * @typedef {object} ChatMessageDataModelMetadata
 * @property {Record<string, ApplicationClickAction>} actions  Default click actions for buttons on the message.
 * @property {string} template                                 Template to use when rendering this message.
 */

/**
 * Abstract base class to add some shared functionality to all of the system's custom chat message types.
 * @abstract
 */
class ChatMessageDataModel extends foundry.abstract.TypeDataModel {

  /**
   * Metadata for this chat message type.
   * @type {ChatMessageDataModelMetadata}
   */
  static metadata = Object.freeze({
    actions: {
      use: ChatMessageDataModel.#useActivity
    },
    template: ""
  });

  get metadata() {
    return this.constructor.metadata;
  }

  /* -------------------------------------------- */

  /**
   * Template to use when rendering this message.
   * @type {string}
   */
  get template() {
    return this.metadata.template;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /**
   * Perform any changes to the chat message's element before displaying in the list.
   * @param {HTMLElement} element  Element representing the entire chat message.
   * @param {object} options       Options forwarded to the render function.
   */
  async getHTML(element, options) {
    const rendered = await this.render(options);
    if ( rendered ) element.querySelector(".message-content").innerHTML = rendered;
    this.parent._enrichChatCard(element);

    const click = this.#onClick.bind(this);
    element.addEventListener("click", click);
    element.addEventListener("contextmenu", click);
    this._onRender(element);
  }

  /* -------------------------------------------- */

  /**
   * Render the contents of this chat message.
   * @param {object} options  Rendering options.
   * @returns {Promise<string>}
   */
  async render(options) {
    if ( !this.template ) return "";
    return renderTemplate(this.template, await this._prepareContext(options));
  }

  /* -------------------------------------------- */

  /**
   * Prepare application rendering context data for a given render request.
   * @param {object} options  Rendering options.
   * @returns {Promise<ApplicationRenderContext>}   Context data for the render operation.
   * @protected
   */
  async _prepareContext(options) {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Actions taken after the message has been rendered.
   * @param {HTMLElement} element
   * @protected
   */
  _onRender(element) {
    for ( const e of element.querySelectorAll(".item-tooltip") ) {
      const uuid = e.closest("[data-item-uuid]")?.dataset.itemUuid;
      if ( !uuid ) continue;
      Object.assign(e.dataset, {
        tooltip: `<section class="loading" data-uuid="${uuid}"><i class="fas fa-spinner fa-spin-pulse"></i></section>`,
        tooltipClass: "dnd5e2 dnd5e-tooltip item-tooltip",
        tooltipDirection: "LEFT"
      });
    }
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Handle click events within the card.
   * @param {PointerEvent} event  Triggering pointer event.
   */
  #onClick(event) {
    const target = event.target.closest("[data-action]");
    if ( target ) {
      const action = target.dataset.action;
      let handler = this.metadata.actions[action];
      if ( handler ) {
        let buttons = [0];
        if ( typeof handler === "object" ) {
          buttons = handler.buttons;
          handler = handler.handler;
        }
        if ( buttons.includes(event.button) ) handler?.call(this, event, target);
      } else {
        this._onClickAction(event, target);
      }
    }
  }

  /* -------------------------------------------- */

  /**
   * A generic event handler for action clicks which can be extended by subclasses, called if no action is found in
   * the actions list in the message type's metadata.
   * @param {PointerEvent} event  Triggering pointer event.
   * @param {HTMLElement} target  Button with [data-action] defined.
   * @protected
   */
  _onClickAction(event, target) {}

  /* -------------------------------------------- */

  /**
   * Handle using an activity.
   * @this {ChatMessageDataModel}
   * @param {Event} event         Triggering click event.
   * @param {HTMLElement} target  Button that was clicked.
   */
  static async #useActivity(event, target) {
    target.disabled = true;
    try {
      const activity = await fromUuid(target.closest("[data-activity-uuid]")?.dataset.activityUuid);
      await activity?.use({ event });
    } finally {
      target.disabled = false;
    }
  }
}

var _module$c = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ChatMessageDataModel: ChatMessageDataModel
});

/**
 * System data model for enchantment active effects.
 */
let EnchantmentData$1 = class EnchantmentData extends foundry.abstract.TypeDataModel {
  /** @override */
  static defineSchema() {
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Handle enchantment-specific changes to the item.
   * @param {Item5e} item                The Item to whom this effect should be applied.
   * @param {EffectChangeData} change    The change data being applied.
   * @param {Record<string, *>} changes  The aggregate update paths and their updated values.
   * @returns {boolean|void}             Return false to prevent normal application from occurring.
   */
  _applyLegacy(item, change, changes) {
    let key = change.key.replace("system.", "");
    switch ( change.key ) {
      case "system.ability":
        for ( const activity of item.system.activities?.getByTypes("attack") ?? [] ) {
          changes[`system.activities.${activity.id}.attack.ability`] = ActiveEffect.applyField(
            activity, { ...change, key: "attack.ability" }
          );
        }
        return false;
      case "system.attack.bonus":
      case "system.attack.flat":
        for ( const activity of item.system.activities?.getByTypes("attack") ?? [] ) {
          changes[`system.activities.${activity.id}.${key}`] = ActiveEffect.applyField(activity, { ...change, key });
        }
        return false;
      case "system.damage.bonus":
        change.key = "system.damageBonus";
        break;
      case "system.damage.parts":
        try {
          let damage;
          const parsed = JSON.parse(change.value);
          if ( foundry.utils.getType(parsed) === "Object" ) damage = new DamageData(parsed);
          else damage = new DamageData({ custom: { enabled: true, formula: parsed[0][0] }, types: [parsed[0][1]] });
          for ( const activity of item.system.activities?.getByTypes("attack", "damage", "save") ?? [] ) {
            const value = damage.clone();
            value.enchantment = true;
            value.locked = true;
            changes[`system.activities.${activity.id}.damage.parts`] = ActiveEffect.applyField(
              activity, { ...change, key, value }
            );
          }
          for ( const activity of item.system.activities?.getByTypes("heal") ?? [] ) {
            const value = damage.formula;
            const keyPath = `healing.${activity.healing.custom.enabled ? "custom.formula" : "bonus"}`;
            changes[`system.activities.${activity.id}.${keyPath}`] = ActiveEffect.applyField(
              activity, { ...change, key: keyPath, value }
            );
          }
          return false;
        } catch(err) {}
      case "system.damage.types":
        const adjust = (damage, keyPath) =>
          ActiveEffect.applyField(damage, { ...change, key: "types", value: change.value });
        if ( item.system.damage?.base ) {
          changes["system.damage.base.types"] = adjust(item.system.damage.base);
        }
        for ( const activity of item.system.activities?.getByTypes("attack", "damage", "save") ?? [] ) {
          for ( const part of activity.damage.parts ) adjust(part);
          changes[`system.activities.${activity.id}.damage.parts`] = activity.damage.parts;
        }
        return false;
      case "system.save.dc":
      case "system.save.scaling":
        let value = change.value;
        if ( key === "save.dc" ) key = "save.dc.formula";
        else {
          key = "save.dc.calculation";
          if ( value === "flat" ) value = "";
          else if ( (value === "") && (item.type === "spell") ) value = "spellcasting";
        }
        for ( const activity of item.system.activities?.getByTypes("save") ?? [] ) {
          changes[`system.activities.${activity.id}.${key}`] = ActiveEffect.applyField(
            activity, { ...change, key, value }
          );
        }
        return false;
    }
  }
};

const config$4 = {
  enchantment: EnchantmentData$1
};

var _module$b = /*#__PURE__*/Object.freeze({
  __proto__: null,
  EnchantmentData: EnchantmentData$1,
  config: config$4
});

var _module$a = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AppliedEffectField: AppliedEffectField,
  AttackActivityData: AttackActivityData,
  BaseActivityData: BaseActivityData,
  CastActivityData: CastActivityData,
  CheckActivityData: CheckActivityData,
  ConsumptionError: ConsumptionError,
  ConsumptionTargetData: ConsumptionTargetData,
  ConsumptionTargetsField: ConsumptionTargetsField,
  DamageActivityData: DamageActivityData,
  EnchantActivityData: EnchantActivityData,
  ForwardActivityData: ForwardActivityData,
  HealActivityData: HealActivityData,
  SaveActivityData: SaveActivityData,
  SummonActivityData: SummonActivityData,
  UtilityActivityData: UtilityActivityData
});

const { ForeignDocumentField: ForeignDocumentField$2, SetField: SetField$b } = foundry.data.fields;

/**
 * A custom model to validate system flags on Group Actors.
 *
 * @property {Set<string>} awardDestinations  Saved targets from previous use of award button.
 */
class GroupSystemFlags extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      awardDestinations: new SetField$b(
        new ForeignDocumentField$2(foundry.documents.BaseActor, { idOnly: true }), { required: false }
      )
    };
  }
}

const { ArrayField: ArrayField$a, ForeignDocumentField: ForeignDocumentField$1, HTMLField: HTMLField$4, NumberField: NumberField$c, SchemaField: SchemaField$f, StringField: StringField$l } = foundry.data.fields;

/**
 * Metadata associated with members in this group.
 * @typedef {object} GroupMemberData
 * @property {Actor5e} actor              Associated actor document.
 * @property {object} quantity
 * @property {number} quantity.value      Number of this actor in the group (for encounter or crew types).
 * @property {string} [quantity.formula]  Formula used for re-rolling actor quantities in encounters.
 */

/**
 * A data model and API layer which handles the schema and functionality of "group" type Actors in the dnd5e system.
 * @mixes CurrencyTemplate
 *
 * @property {object} type
 * @property {string} type.value                 Type of group represented (e.g. "Party", "Encounter", "Crew").
 * @property {object} description
 * @property {string} description.full           Description of this group.
 * @property {string} description.summary        Summary description (currently unused).
 * @property {GroupMemberData[]} members         Members in this group with associated metadata.
 * @property {object} attributes
 * @property {object} attributes.movement
 * @property {number} attributes.movement.land   Base movement speed over land.
 * @property {number} attributes.movement.water  Base movement speed over water.
 * @property {number} attributes.movement.air    Base movement speed through the air.
 * @property {object} details
 * @property {object} details.xp
 * @property {number} details.xp.value           XP currently available to be distributed to a party.
 *
 * @example Create a new Group
 * const g = new dnd5e.documents.Actor5e({
 *  type: "group",
 *  name: "Test Group",
 *  system: {
 *    members: [{ actor: "3f3hoYFWUgDqBP4U" }]
 *  }
 * });
 */
class GroupActor extends ActorDataModel.mixin(CurrencyTemplate) {
  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      type: new SchemaField$f({
        value: new StringField$l({ initial: "party", label: "DND5E.Group.Type" })
      }),
      description: new SchemaField$f({
        full: new HTMLField$4({ label: "DND5E.Description" }),
        summary: new HTMLField$4({ label: "DND5E.DescriptionSummary" })
      }),
      members: new ArrayField$a(new SchemaField$f({
        actor: new ForeignDocumentField$1(foundry.documents.BaseActor),
        quantity: new SchemaField$f({
          value: new NumberField$c({ initial: 1, integer: true, min: 0, label: "DND5E.Quantity" }),
          formula: new FormulaField({ label: "DND5E.QuantityFormula" })
        })
      }), { label: "DND5E.GroupMembers" }),
      attributes: new SchemaField$f({
        movement: new SchemaField$f({
          land: new NumberField$c({ nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementLand" }),
          water: new NumberField$c({ nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementWater" }),
          air: new NumberField$c({ nullable: false, min: 0, step: 0.1, initial: 0, label: "DND5E.MovementAir" })
        })
      }, { label: "DND5E.Attributes" }),
      details: new SchemaField$f({
        xp: new SchemaField$f({
          value: new NumberField$c({ integer: true, min: 0, label: "DND5E.ExperiencePoints.Current" })
        }, { label: "DND5E.ExperiencePoints.Label" })
      }, { label: "DND5E.Details" })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    systemFlagsModel: GroupSystemFlags
  }, {inplace: false}));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /** @override */
  get transferDestinations() {
    return this.members.map(m => m.actor).filter(a => a.isOwner);
  }

  /* -------------------------------------------- */

  /**
   * Return only the group members that are characters.
   * @type {Actor5e[]}
   */
  get playerCharacters() {
    return this.members.map(m => m.actor).filter(a => a.type === "character");
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    GroupActor.#migrateMembers(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate group members from set of IDs into array of metadata objects.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateMembers(source) {
    if ( foundry.utils.getType(source.members) !== "Array" ) return;
    source.members = source.members.map(m => {
      if ( foundry.utils.getType(m) === "Object" ) return m;
      return { actor: m };
    });
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    const memberIds = new Set();
    this.members = this.members.filter((member, index) => {
      if ( !member.actor ) {
        const id = this._source.members[index]?.actor;
        console.warn(`Actor "${id}" in group "${this._id}" does not exist within the World.`);
      } else if ( member.actor.type === "group" ) {
        console.warn(`Group "${this._id}" may not contain another Group "${member.actor.id}" as a member.`);
      } else if ( memberIds.has(member.actor.id) ) {
        console.warn(`Actor "${member.actor.id}" duplicated in Group "${this._id}".`);
      } else {
        memberIds.add(member.actor.id);
        return true;
      }
      return false;
    });
    Object.defineProperty(this.members, "ids", {
      value: memberIds,
      enumerable: false,
      writable: false
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const system = this;
    Object.defineProperty(this.details.xp, "derived", {
      get() {
        return system.type.value === "encounter" ? system.members.reduce((xp, { actor, quantity }) =>
          xp + ((actor.system.details?.xp?.value ?? 0) * (quantity.value ?? 1))
        , 0) : null;
      },
      configurable: true,
      enumerable: false
    });
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Add a new member to the group.
   * @param {Actor5e} actor           A non-group Actor to add to the group
   * @returns {Promise<Actor5e>}      The updated group Actor
   */
  async addMember(actor) {
    if ( actor.type === "group" ) throw new Error("You may not add a group within a group.");
    if ( actor.pack ) throw new Error("You may only add Actors to the group which exist within the World.");
    if ( this.members.ids.has(actor.id) ) return;
    const membersCollection = this.toObject().members;
    membersCollection.push({ actor: actor.id });
    return this.parent.update({"system.members": membersCollection});
  }

  /* -------------------------------------------- */

  /**
   * Place all members in the group on the current scene.
   */
  async placeMembers() {
    if ( !game.user.isGM || !canvas.scene ) return;
    const minimized = !this.parent.sheet._minimized;
    await this.parent.sheet.minimize();
    const tokensData = [];

    try {
      const placements = await TokenPlacement.place({
        tokens: Object.values(this.members).flatMap(({ actor, quantity }) =>
          Array(this.type.value === "encounter" ? (quantity.value ?? 1) : 1).fill(actor.prototypeToken)
        )
      });
      for ( const placement of placements ) {
        const actor = placement.prototypeToken.actor;
        const appendNumber = !placement.prototypeToken.actorLink && placement.prototypeToken.appendNumber;
        delete placement.prototypeToken;
        const tokenDocument = await actor.getTokenDocument(placement);
        if ( appendNumber ) TokenPlacement.adjustAppendedNumber(tokenDocument, placement);
        tokensData.push(tokenDocument.toObject());
      }
    } finally {
      if ( minimized ) this.parent.sheet.maximize();
    }

    await canvas.scene.createEmbeddedDocuments("Token", tokensData);
  }

  /* -------------------------------------------- */

  /**
   * Remove a member from the group.
   * @param {Actor5e|string} actor    An Actor or ID to remove from this group
   * @returns {Promise<Actor5e>}      The updated group Actor
   */
  async removeMember(actor) {
    // Handle user input
    let actorId;
    if ( typeof actor === "string" ) actorId = actor;
    else if ( actor instanceof Actor ) actorId = actor.id;
    else throw new Error("You must provide an Actor document or an actor ID to remove a group member");
    if ( !this.members.ids.has(actorId) ) throw new Error(`Actor id "${actorId}" is not a group member`);

    // Remove the actor and update the parent document
    const membersCollection = this.toObject().members;
    membersCollection.findSplice(member => member.actor === actorId);
    return this.parent.update({"system.members": membersCollection});
  }

  /* -------------------------------------------- */

  /**
   * Roll the quantity formulas for each member and replace their quantity. Any entries without formulas
   * will not be modified.
   * @returns {Promise<Actor5e>}
   */
  async rollQuantities() {
    const membersCollection = this.toObject().members;
    await Promise.all(membersCollection.map(async member => {
      if ( !member.quantity?.formula ) return member;
      const roll = new Roll(member.quantity.formula);
      await roll.evaluate();
      if ( roll.total > 0 ) member.quantity.value = roll.total;
    }));
    return this.parent.update({"system.members": membersCollection});
  }

  /* -------------------------------------------- */
  /*  Resting                                     */
  /* -------------------------------------------- */

  /**
   * Initiate a rest for all members of this group.
   * @param {RestConfiguration} config  Configuration data for the rest.
   * @param {RestResult} result         Results of the rest operation being built.
   * @returns {boolean}                 Returns `false` to prevent regular rest process from completing.
   */
  async rest(config, result) {
    const results = new Map();
    for ( const member of this.members ) {
      results.set(
        member.actor,
        await member.actor[config.type === "short" ? "shortRest" : "longRest"]({
          ...config, dialog: false, advanceBastionTurn: false, advanceTime: false
        }) ?? null
      );
    }

    // Advance the game clock
    if ( config.advanceTime && (config.duration > 0) && game.user.isGM ) await game.time.advance(60 * config.duration);

    /**
     * A hook event that fires when the rest process is completed for a group.
     * @function dnd5e.groupRestCompleted
     * @memberof hookEvents
     * @param {Actor5e} group                         The group that just completed resting.
     * @param {Map<Actor5e, RestResult|null>} result  Details on the rests completed.
     */
    Hooks.callAll("dnd5e.groupRestCompleted", this.parent, results);

    if ( config.advanceBastionTurn && game.user.isGM && game.settings.get("dnd5e", "bastionConfiguration").enabled ) {
      await dnd5e.bastion.advanceAllBastions();
    }

    return false;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /**
   * If type has been set to something other than "party" and this is currently the primary party, remove that setting.
   * @param {object} changed   The differential data that was changed relative to the documents prior values
   * @param {object} options   Additional options which modify the update request
   * @param {string} userId    The id of the User requesting the document update
   * @see {Document#_onUpdate}
   * @protected
   */
  _onUpdate(changed, options, userId) {
    if ( !foundry.utils.hasProperty(changed, "system.type.value") || (game.user !== game.users.activeGM)
      || (game.settings.get("dnd5e", "primaryParty")?.actor !== this.parent)
      || (foundry.utils.getProperty(changed, "system.type.value") === "party") ) return;
    game.settings.set("dnd5e", "primaryParty", { actor: null });
  }
}

const { ArrayField: ArrayField$9, BooleanField: BooleanField$9, NumberField: NumberField$b, SchemaField: SchemaField$e, SetField: SetField$a, StringField: StringField$k } = foundry.data.fields;

/**
 * @typedef NPCHabitatData
 * @property {string} type       The habitat category.
 * @property {string} [subtype]  An optional discriminator for the main category.
 */

/**
 * System data definition for NPCs.
 *
 * @property {object} attributes
 * @property {object} attributes.hd
 * @property {number} attributes.hd.spent        Number of hit dice spent.
 * @property {object} attributes.hp
 * @property {number} attributes.hp.value        Current hit points.
 * @property {number} attributes.hp.max          Maximum allowed HP value.
 * @property {number} attributes.hp.temp         Temporary HP applied on top of value.
 * @property {number} attributes.hp.tempmax      Temporary change to the maximum HP.
 * @property {string} attributes.hp.formula      Formula used to determine hit points.
 * @property {object} attributes.death
 * @property {object} attributes.death.bonuses
 * @property {string} attributes.death.bonuses.save   Numeric or dice bonus to death saving throws.
 * @property {number} attributes.death.success        Number of successful death saves.
 * @property {number} attributes.death.failure        Number of failed death saves.
 * @property {object} attributes.spell
 * @property {number} attributes.spell.level     Spellcasting level of this NPC.
 * @property {object} details
 * @property {TypeData} details.type             Creature type of this NPC.
 * @property {string} details.type.value         NPC's type as defined in the system configuration.
 * @property {string} details.type.subtype       NPC's subtype usually displayed in parenthesis after main type.
 * @property {string} details.type.swarm         Size of the individual creatures in a swarm, if a swarm.
 * @property {string} details.type.custom        Custom type beyond what is available in the configuration.
 * @property {object} details.habitat
 * @property {NPCHabitatData[]} details.habitat.value  Common habitats in which this NPC is found.
 * @property {string} details.habitat.custom     Custom habitats.
 * @property {object} details.treasure
 * @property {Set<string>} details.treasure.value  Random treasure generation categories for this NPC.
 * @property {number} details.cr                 NPC's challenge rating.
 * @property {object} resources
 * @property {object} resources.legact           NPC's legendary actions.
 * @property {number} resources.legact.value     Currently available legendary actions.
 * @property {number} resources.legact.max       Maximum number of legendary actions.
 * @property {object} resources.legres           NPC's legendary resistances.
 * @property {number} resources.legres.value     Currently available legendary resistances.
 * @property {number} resources.legres.max       Maximum number of legendary resistances.
 * @property {object} resources.lair             NPC's lair actions.
 * @property {boolean} resources.lair.value      This creature can possess a lair (2024) or take lair actions (2014).
 * @property {number} resources.lair.initiative  Initiative count when lair actions are triggered.
 * @property {boolean} resources.lair.inside     This actor is currently inside its lair.
 * @property {SourceData} source                 Adventure or sourcebook where this NPC originated.
 */
class NPCData extends CreatureTemplate {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.NPC", "DND5E.BONUSES", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    supportsAdvancement: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _systemType = "npc";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      attributes: new SchemaField$e({
        ...AttributesFields.common,
        ...AttributesFields.creature,
        hd: new SchemaField$e({
          spent: new NumberField$b({integer: true, min: 0, initial: 0})
        }, {label: "DND5E.HitDice"}),
        hp: new SchemaField$e({
          value: new NumberField$b({
            nullable: false, integer: true, min: 0, initial: 10, label: "DND5E.HitPointsCurrent"
          }),
          max: new NumberField$b({
            nullable: false, integer: true, min: 0, initial: 10, label: "DND5E.HitPointsMax"
          }),
          temp: new NumberField$b({integer: true, initial: 0, min: 0, label: "DND5E.HitPointsTemp"}),
          tempmax: new NumberField$b({
            integer: true, initial: 0, label: "DND5E.HitPointsTempMax", hint: "DND5E.HitPointsTempMaxHint"
          }),
          formula: new FormulaField({required: true, label: "DND5E.HPFormula"})
        }, {label: "DND5E.HitPoints"}),
        death: new RollConfigField({
          ability: false,
          success: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveSuccesses"
          }),
          failure: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.DeathSaveFailures"
          }),
          bonuses: new SchemaField$e({
            save: new FormulaField({ required: true, label: "DND5E.DeathSaveBonus" })
          })
        }, {label: "DND5E.DeathSave"}),
        spell: new SchemaField$e({
          level: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.SpellcasterLevel"
          })
        })
      }, {label: "DND5E.Attributes"}),
      details: new SchemaField$e({
        ...DetailsField.common,
        ...DetailsField.creature,
        type: new CreatureTypeField(),
        habitat: new SchemaField$e({
          value: new ArrayField$9(new SchemaField$e({
            type: new StringField$k({ required: true }),
            subtype: new StringField$k()
          })),
          custom: new StringField$k({ required: true })
        }),
        cr: new NumberField$b({
          required: true, nullable: true, min: 0, initial: 1, label: "DND5E.ChallengeRating"
        }),
        treasure: new SchemaField$e({
          value: new SetField$a(new StringField$k())
        })
      }, {label: "DND5E.Details"}),
      resources: new SchemaField$e({
        legact: new SchemaField$e({
          value: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryAction.Remaining"
          }),
          max: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0, label: "DND5E.LegendaryAction.Max"
          })
        }, {label: "DND5E.LegendaryAction.Label"}),
        legres: new SchemaField$e({
          value: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0,
            label: "DND5E.LegendaryResistance.Remaining"
          }),
          max: new NumberField$b({
            required: true, nullable: false, integer: true, min: 0, initial: 0,
            label: "DND5E.LegendaryResistance.Max"
          })
        }, {label: "DND5E.LegendaryResistance.Label"}),
        lair: new SchemaField$e({
          value: new BooleanField$9({required: true, label: "DND5E.LAIR.Action.Uses"}),
          initiative: new NumberField$b({
            required: true, integer: true, label: "DND5E.LAIR.Action.Initiative"
          }),
          inside: new BooleanField$9({ label: "DND5E.LAIR.Inside" })
        }, {label: "DND5E.LAIR.Action.Label"})
      }, {label: "DND5E.Resources"}),
      source: new SourceField(),
      traits: new SchemaField$e({
        ...TraitsField.common,
        ...TraitsField.creature,
        important: new BooleanField$9()
      }, {label: "DND5E.Traits"})
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["size", {
        label: "DND5E.Size",
        type: "set",
        config: {
          choices: CONFIG.DND5E.actorSizes,
          keyPath: "system.traits.size"
        }
      }],
      ["type", {
        label: "DND5E.CreatureType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.creatureTypes,
          keyPath: "system.details.type.value"
        }
      }],
      ["habitat", {
        label: "DND5E.Habitat.Configuration.Label",
        type: "set",
        config: {
          choices: CONFIG.DND5E.habitats
        },
        createFilter: (filters, value, def) => {
          const { include, exclude } = Object.entries(value).reduce((d, [key, value]) => {
            if ( value === 1 ) d.include.push(key);
            else if ( value === -1 ) d.exclude.push(key);
            return d;
          }, { include: [], exclude: [] });
          if ( include.length ) filters.push({
            k: "system.details.habitat.value", o: "has", v: { k: "type", o: "in", v: include }
          });
          if ( exclude.length ) filters.push({
            o: "NOT", v: { k: "system.details.habitat.value", o: "has", v: { k: "type", o: "in", v: exclude } }
          });
        }
      }],
      ["cr", {
        label: "DND5E.ChallengeRating",
        type: "range",
        config: {
          keyPath: "system.details.cr",
          min: 0,
          max: 30
        }
      }],
      ["movement", {
        label: "DND5E.Movement",
        type: "set",
        config: {
          choices: CONFIG.DND5E.movementTypes
        },
        createFilter: (filters, value, def) => {
          for ( const [k, v] of Object.entries(value ?? {}) ) {
            if ( v === 1 ) filters.push({ k: `system.attributes.movement.${k}`, o: "gt", v: 0 });
            if ( v === -1 ) filters.push({ k: `system.attributes.movement.${k}`, v: 0 });
          }
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    NPCData.#migrateEnvironment(source);
    NPCData.#migrateSource(source);
    NPCData.#migrateSpellLevel(source);
    NPCData.#migrateTypeData(source);
    AttributesFields._migrateInitiative(source.attributes);
  }

  /* -------------------------------------------- */

  /**
   * Convert the plain string environment to a custom habitat.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateEnvironment(source) {
    const custom = source.details?.environment;
    if ( (typeof custom === "string") && !("habitat" in source.details) ) source.details.habitat = { custom };
  }

  /* -------------------------------------------- */

  /**
   * Convert source string into custom object & move to top-level.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSource(source) {
    let custom;
    if ( ("details" in source) && ("source" in source.details) ) {
      if ( foundry.utils.getType(source.details?.source) === "string" ) custom = source.details.source;
      else source.source = { ...(source.source ?? {}), ...source.details.source };
    }
    if ( custom ) {
      source.source ??= {};
      source.source.custom = custom;
    }
  }

  /* -------------------------------------------- */

  /**
   * Move spell level from `details.spellLevel` to `attributes.spell.level`.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSpellLevel(source) {
    if ( source.details?.spellLevel !== undefined ) {
      foundry.utils.setProperty(source, "attributes.spell.level", source.details.spellLevel);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the actor type string to type object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateTypeData(source) {
    const original = source.type;
    if ( typeof original !== "string" ) return;

    source.type = {
      value: "",
      subtype: "",
      swarm: "",
      custom: ""
    };

    // Match the existing string
    const pattern = /^(?:swarm of (?<size>[\w-]+) )?(?<type>[^(]+?)(?:\((?<subtype>[^)]+)\))?$/i;
    const match = original.trim().match(pattern);
    if ( match ) {

      // Match a known creature type
      const typeLc = match.groups.type.trim().toLowerCase();
      const typeMatch = Object.entries(CONFIG.DND5E.creatureTypes).find(([k, v]) => {
        return (typeLc === k)
          || (typeLc === game.i18n.localize(v.label).toLowerCase())
          || (typeLc === game.i18n.localize(`${v.label}Pl`).toLowerCase());
      });
      if ( typeMatch ) source.type.value = typeMatch[0];
      else {
        source.type.value = "custom";
        source.type.custom = match.groups.type.trim().titleCase();
      }
      source.type.subtype = match.groups.subtype?.trim().titleCase() ?? "";

      // Match a swarm
      if ( match.groups.size ) {
        const sizeLc = match.groups.size ? match.groups.size.trim().toLowerCase() : "tiny";
        const sizeMatch = Object.entries(CONFIG.DND5E.actorSizes).find(([k, v]) => {
          return (sizeLc === k) || (sizeLc === game.i18n.localize(v.label).toLowerCase());
        });
        source.type.swarm = sizeMatch ? sizeMatch[0] : "tiny";
      }
      else source.type.swarm = "";
    }

    // No match found
    else {
      source.type.value = "custom";
      source.type.custom = original;
    }
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.details.level = 0;
    this.attributes.attunement.value = 0;

    // Determine hit dice denomination & max from hit points formula
    const [, max, denomination] = this.attributes.hp.formula?.match(/(\d*)d(\d+)/i) ?? [];
    this.attributes.hd.max = Number(max ?? 0);
    this.attributes.hd.denomination = Number(denomination ?? CONFIG.DND5E.actorSizes[this.traits.size]?.hitDie ?? 4);

    for ( const item of this.parent.items ) {
      // Class levels & hit dice
      if ( item.type === "class" ) {
        const classLevels = parseInt(item.system.levels) ?? 1;
        this.details.level += classLevels;
        this.attributes.hd.max += classLevels;
      }

      // Attuned items
      else if ( item.system.attuned ) this.attributes.attunement.value += 1;
    }

    const lairAdjustment = Number(this.resources.lair.value && this.resources.lair.inside);

    // Kill Experience
    this.details.xp ??= {};
    this.details.xp.value = this.parent.getCRExp(this.details.cr === null ? null : this.details.cr + lairAdjustment);

    // Legendary Resistances/Actions
    if ( this.resources.legact.max ) this.resources.legact.max += lairAdjustment;
    if ( this.resources.legres.max ) this.resources.legres.max += lairAdjustment;

    // Proficiency
    if ( this.details.cr === null ) this.attributes.prof = null;
    else this.attributes.prof = Proficiency.calculateMod(Math.max(this.details.cr, this.details.level, 1));

    // Spellcaster Level
    const attributes = this.attributes;
    Object.defineProperty(this.details, "spellLevel", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "The `details.spellLevel` property on NPCs have moved to `attributes.spell.level`.",
          { since: "DnD5e 4.3", until: "DnD5e 5.0" }
        );
        return attributes.spell.level;
      },
      set(value) {
        foundry.utils.logCompatibilityWarning(
          "The `details.spellLevel` property on NPCs have moved to `attributes.spell.level`.",
          { since: "DnD5e 4.3", until: "DnD5e 5.0" }
        );
        attributes.spell.level = value;
      }
    });
    if ( this.attributes.spellcasting && !Number.isNumeric(this.attributes.spell.level) ) {
      this.attributes.spell.level = Math.max(this.details.cr, 1);
    }

    AttributesFields.prepareBaseArmorClass.call(this);
    AttributesFields.prepareBaseEncumbrance.call(this);
    SourceField.shimActor.call(this);
  }

  /* -------------------------------------------- */

  /**
   * Prepare movement & senses values derived from race item.
   */
  prepareEmbeddedData() {
    super.prepareEmbeddedData();
    if ( this.details.race instanceof Item ) {
      AttributesFields.prepareRace.call(this, this.details.race, { force: true });
      this.details.type = this.details.race.system.type;
    }
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) this.attributes.movement[key] ??= 0;
    for ( const key of Object.keys(CONFIG.DND5E.senses) ) this.attributes.senses[key] ??= 0;
    this.attributes.movement.units ??= defaultUnits("length");
    this.attributes.senses.units ??= defaultUnits("length");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const { originalSaves, originalSkills } = this.parent.getOriginalStats();

    this.prepareAbilities({ rollData, originalSaves });
    this.prepareSkills({ rollData, originalSkills });
    this.prepareTools({ rollData });
    AttributesFields.prepareArmorClass.call(this, rollData);
    AttributesFields.prepareConcentration.call(this, rollData);
    AttributesFields.prepareEncumbrance.call(this, rollData);
    AttributesFields.prepareExhaustionLevel.call(this);
    AttributesFields.prepareInitiative.call(this, rollData);
    AttributesFields.prepareMovement.call(this);
    AttributesFields.prepareSpellcastingAbility.call(this);
    SourceField.prepareData.call(this.source, this.parent._stats?.compendiumSource ?? this.parent.uuid);
    TraitsField.prepareLanguages.call(this);
    TraitsField.prepareResistImmune.call(this);

    // Hit Dice
    const { hd } = this.attributes;
    hd.value = Math.max(0, hd.max - hd.spent);
    hd.pct = Math.clamp(hd.max ? (hd.value / hd.max) * 100 : 0, 0, 100);

    // Hit Points
    const hpOptions = {
      advancement: Object.values(this.parent.classes).map(c => c.advancement.byType.HitPoints?.[0]).filter(a => a),
      mod: this.abilities[CONFIG.DND5E.defaultAbilities.hitPoints ?? "con"]?.mod ?? 0
    };
    AttributesFields.prepareHitPoints.call(this, this.attributes.hp, hpOptions);

    this.resources.legact.label = this.getLegendaryActionsDescription();
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    for ( const key of ["legact", "legres"] ) {
      const max = foundry.utils.getProperty(changed, `system.resources.${key}.max`);
      if ( (max !== undefined) && !foundry.utils.hasProperty(changed, `system.resources.${key}.value`) ) {
        const delta = max - foundry.utils.getProperty(this, `resources.${key}.max`);
        const value = foundry.utils.getProperty(this, `resources.${key}.value`);
        foundry.utils.setProperty(changed, `system.resources.${key}.value`, value + delta);
      }
    }
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Level used to determine cantrip scaling.
   * @param {Item5e} spell  Spell for which to fetch the cantrip level.
   * @returns {number}
   */
  cantripLevel(spell) {
    if ( spell.system.preparation.mode === "innate" ) return this.details.cr;
    return this.details.level ? this.details.level : this.attributes.spell.level;
  }

  /* -------------------------------------------- */

  /**
   * Auto-generate a description for the legendary actions block on the NPC stat block.
   * @param {string} name  Name of the actor to use in the text.
   * @returns {string}
   */
  getLegendaryActionsDescription(name=this.parent.name) {
    const max = this._source.resources.legact.max;
    if ( !max ) return "";
    const pr = getPluralRules().select(max);
    const rulesVersion = this.source?.rules
      || (game.settings.get("dnd5e", "rulesVersion") === "modern" ? "2024" : "2014");
    return game.i18n.format(`DND5E.LegendaryAction.Description${rulesVersion === "2014" ? "Legacy" : ""}`, {
      name: name.toLowerCase(),
      uses: this.resources.lair.value ? game.i18n.format("DND5E.LegendaryAction.LairUses", {
        normal: formatNumber(max), lair: formatNumber(max + 1)
      }) : formatNumber(max),
      usesNamed: game.i18n.format(`DND5E.ACTIVATION.Type.Legendary.Counted.${pr}`, { number: formatNumber(max) })
    });
  }

  /* -------------------------------------------- */

  /**
   * Create a list of gear that can be collected from this NPC.
   * @type {Item5e[]}
   */
  getGear() {
    return this.parent.items
      .filter(i => i.system.quantity && (i.system.type?.value !== "natural"))
      .sort((lhs, rhs) => lhs.sort - rhs.sort);
  }

  /* -------------------------------------------- */

  /** @override */
  async recoverCombatUses(periods, results) {
    // Reset legendary actions at the start of a combat encounter or at the end of the creature's turn
    if ( this.resources.legact.max && (periods.includes("encounter") || periods.includes("turnEnd")) ) {
      results.actor["system.resources.legact.value"] = this.resources.legact.max;
    }
  }

  /* -------------------------------------------- */

  /**
   * Spend a legendary resistance to change a failed saving throw into a success.
   * @param {ChatMessage5e} message  The chat message containing the failed save.
   */
  async resistSave(message) {
    if ( this.resources.legres.value === 0 ) throw new Error("No legendary resistances remaining.");
    if ( message.flags.dnd5e?.roll?.type !== "save" ) throw new Error("Chat message must contain a save roll.");
    if ( message.flags.dnd5e?.roll?.forceSuccess ) throw new Error("Save has already been resisted.");
    await this.parent.update({ "system.resources.legres.value": this.resources.legres.value - 1 });
    await message.setFlag("dnd5e", "roll.forceSuccess", true);
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    for ( const value of config.values ) {
      if ( value === "statblock" ) config.statblock = true;
    }
    if ( !config.statblock ) return super.toEmbed(config, options);

    const context = await this._prepareEmbedContext();
    context.name = config.label || this.parent.name;
    if ( config.cite && !config.inline ) {
      config.cite = false;
      context.anchor = this.parent.toAnchor({ name: context.name }).outerHTML;
    }
    const template = document.createElement("template");
    template.innerHTML = await renderTemplate("systems/dnd5e/templates/actors/embeds/npc-embed.hbs", context);

    /**
     * A hook event that fires after an embedded NPC stat block rendered.
     * @function dnd5e.renderNPCStatBlock
     * @memberof hookEvents
     * @param {Actor5e} actor                   NPC being embedded.
     * @param {HTMLTemplateElement} template    Template whose children will be embedded.
     * @param {DocumentHTMLEmbedConfig} config  Configuration for embedding behavior.
     * @param {EnrichmentOptions} options       Original enrichment options.
     */
    Hooks.call("dnd5e.renderNPCStatBlock", this.parent, template, config, options);

    return template.content;
  }

  /* -------------------------------------------- */

  /**
   * Prepare the context information for the embed template rendering.
   * @returns {object}
   */
  async _prepareEmbedContext() {
    const formatter = game.i18n.getListFormatter({ type: "unit" });
    const prepareMeasured = (value, units, label) => label ? `${label} ${formatLength(value, units)}`
      : formatLength(value, units);
    const prepareTrait = ({ value, custom }, trait$1) => formatter.format([
      ...Array.from(value).map(t => keyLabel(t, { trait: trait$1 })).filter(_ => _),
      ...splitSemicolons(custom ?? "")
    ].sort((lhs, rhs) => lhs.localeCompare(rhs, game.i18n.lang)));

    const context = {
      abilityTables: Array.fromRange(3).map(_ => ({ abilities: [] })),
      actionSections: {
        trait: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.Traits"),
          actions: []
        },
        action: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.Actions"),
          actions: []
        },
        bonus: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.BonusActions"),
          actions: []
        },
        reaction: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.Reactions"),
          actions: []
        },
        legendary: {
          label: game.i18n.localize("DND5E.NPC.SECTIONS.LegendaryActions"),
          description: "",
          actions: []
        }
      },
      document: this.parent,
      summary: {
        // Challenge Rating (e.g. `23 (XP 50,000; PB +7`))
        cr: `${formatCR(this.details.cr, { narrow: false })} (${
          game.i18n.format(`DND5E.ExperiencePoints.StatBlock.${
            (this.resources.lair.value) && (this.details.cr !== null) ? "Lair" : "Standard"}`, {
            value: formatNumber(this.parent.getCRExp(this.details.cr)),
            lair: formatNumber(this.parent.getCRExp(this.details.cr + 1))
          })}; ${game.i18n.localize("DND5E.ProficiencyBonusAbbr")} ${
          formatNumber(this.attributes.prof, { signDisplay: "always" })})`,

        // Gear
        gear: formatter.format(
          this.getGear().map(i => i.system.quantity > 1 ? `${i.name} (${formatNumber(i.system.quantity)})` : i.name)
        ),

        // Initiative (e.g. `+0 (10)`)
        initiative: `${formatNumber(this.attributes.init.total, { signDisplay: "always" })} (${
          formatNumber(this.attributes.init.score)})`,

        // Languages (e.g. `Common, Draconic`)
        languages: [
          formatter.format(this.traits.languages.labels.languages),
          formatter.format(this.traits.languages.labels.ranged)
        ].filterJoin("; ") || game.i18n.localize("None"),

        // Senses (e.g. `Blindsight 60 ft., Darkvision 120 ft.; Passive Perception 27`)
        senses: [
          formatter.format([
            ...Object.entries(CONFIG.DND5E.senses)
              .filter(([k]) => this.attributes.senses[k])
              .map(([k, label]) => prepareMeasured(this.attributes.senses[k], this.attributes.senses.units, label)),
            ...splitSemicolons(this.attributes.senses.special)
          ].sort((lhs, rhs) => lhs.localeCompare(rhs, game.i18n.lang))),
          `${game.i18n.localize("DND5E.PassivePerception")} ${formatNumber(this.skills.prc.passive)}`
        ].filterJoin("; "),

        // Skills (e.g. `Perception +17, Stealth +7`)
        skills: formatter.format(
          Object.entries(CONFIG.DND5E.skills)
            .filter(([k]) => this.skills[k].value > 0)
            .map(([k, { label }]) => `${label} ${formatNumber(this.skills[k].total, { signDisplay: "always" })}`)
        ),

        // Speed (e.g. `40 ft., Burrow 40 ft., Fly 80 ft.`)
        speed: formatter.format([
          prepareMeasured(this.attributes.movement.walk, this.attributes.movement.units),
          ...Object.entries(CONFIG.DND5E.movementTypes)
            .filter(([k]) => this.attributes.movement[k] && (k !== "walk"))
            .map(([k, label]) => {
              let prepared = prepareMeasured(this.attributes.movement[k], this.attributes.movement.units, label);
              if ( (k === "fly") && this.attributes.movement.hover ) {
                prepared = `${prepared} (${game.i18n.localize("DND5E.MovementHover").toLowerCase()})`;
              }
              return prepared;
            })
        ]),

        // Tag (e.g. `Gargantuan Dragon, Lawful Evil`)
        tag: game.i18n.format("DND5E.CreatureTag", {
          size: CONFIG.DND5E.actorSizes[this.traits.size]?.label ?? "",
          type: Actor5e.formatCreatureType(this.details.type),
          alignment: this.details.alignment
        }).replace(/, $/, "")
      },
      system: this
    };

    for ( const [index, [key, { abbreviation }]] of Object.entries(CONFIG.DND5E.abilities).entries() ) {
      context.abilityTables[index % 3].abilities.push({ ...this.abilities[key], label: abbreviation.capitalize() });
    }

    for ( const type of ["vulnerabilities", "resistances", "immunities"] ) {
      const entries = [];
      for ( const category of ["damage", "condition"] ) {
        if ( (category === "condition") && (type !== "immunities") ) continue;
        const trait = `${category[0]}${type[0]}`;
        const data = this.traits[trait];
        const { value, physical } = data.value.reduce((acc, t) => {
          if ( data.bypasses?.size && CONFIG.DND5E.damageTypes[t]?.isPhysical ) acc.physical.push(t);
          else acc.value.push(t);
          return acc;
        }, { value: [], physical: [] });
        const list = prepareTrait({ value, custom: data.custom }, trait);
        if ( list ) entries.push(list);
        if ( physical.length ) entries.push(game.i18n.format("DND5E.DamagePhysicalBypasses", {
          damageTypes: game.i18n.getListFormatter({ style: "long", type: "conjunction" }).format(
            physical.map(t => CONFIG.DND5E.damageTypes[t].label)
          ),
          bypassTypes: game.i18n.getListFormatter({ style: "long", type: "disjunction" }).format(
            Array.from(data.bypasses).map(t => CONFIG.DND5E.itemProperties[t]?.label).filter(_ => _)
          )
        }));
      }
      if ( entries.length ) context.summary[type] = entries.join("; ");
    }

    for ( const item of this.parent.items ) {
      if ( !["feat", "weapon"].includes(item.type) ) continue;
      const category = item.system.properties.has("trait") ? "trait"
        : (item.system.activities?.contents[0]?.activation?.type ?? "trait");
      if ( category in context.actionSections ) {
        let description = (await TextEditor.enrichHTML(item.system.description.value, {
          secrets: false, rollData: item.getRollData(), relativeTo: item
        }));
        if ( item.identifier === "legendary-actions" ) {
          context.actionSections.legendary.description = description;
        } else {
          const openingTag = description.match(/^\s*(<p(?:\s[^>]+)?>)/gi)?.[0];
          if ( openingTag ) description = description.replace(openingTag, "");
          const uses = item.system.uses.label || item.system.activities?.contents[0]?.uses.label;
          context.actionSections[category].actions.push({
            description, openingTag,
            name: uses ? `${item.name} (${uses})` : item.name,
            sort: item.sort
          });
        }
      }
    }
    for ( const [key, section] of Object.entries(context.actionSections) ) {
      if ( section.actions.length ) {
        section.actions.sort((lhs, rhs) => lhs.sort - rhs.sort);
        if ( (key === "legendary") && !section.description ) {
          section.description = `<p>${this.getLegendaryActionsDescription()}</p>`;
        }
      } else delete context.actionSections[key];
    }

    return context;
  }
}

const { ArrayField: ArrayField$8, BooleanField: BooleanField$8, NumberField: NumberField$a, SchemaField: SchemaField$d, StringField: StringField$j } = foundry.data.fields;

/**
 * @import { SourceData } from "../shared/source-field.mjs"
 * @import { ArmorClassData } from "./templates/attributes.mjs"
 */

/**
 * System data definition for Vehicles.
 *
 * @property {string} vehicleType                      Type of vehicle as defined in `DND5E.vehicleTypes`.
 * @property {object} attributes
 * @property {ArmorClassData} attributes.ac
 * @property {string} attributes.ac.motionless         Changes to vehicle AC when not moving.
 * @property {object} attributes.hp
 * @property {number} attributes.hp.value              Current hit points.
 * @property {number} attributes.hp.max                Maximum allowed HP value.
 * @property {number} attributes.hp.temp               Temporary HP applied on top of value.
 * @property {number} attributes.hp.tempmax            Temporary change to the maximum HP.
 * @property {number} attributes.hp.dt                 Damage threshold.
 * @property {number} attributes.hp.mt                 Mishap threshold.
 * @property {object} attributes.actions               Information on how the vehicle performs actions.
 * @property {boolean} attributes.actions.stations     Does this vehicle rely on action stations that required
 *                                                     individual crewing rather than general crew thresholds?
 * @property {number} attributes.actions.value         Maximum number of actions available with full crewing.
 * @property {object} attributes.actions.thresholds    Crew thresholds needed to perform various actions.
 * @property {number} attributes.actions.thresholds.2  Minimum crew needed to take full action complement.
 * @property {number} attributes.actions.thresholds.1  Minimum crew needed to take reduced action complement.
 * @property {number} attributes.actions.thresholds.0  Minimum crew needed to perform any actions.
 * @property {object} attributes.capacity              Information on the vehicle's carrying capacity.
 * @property {string} attributes.capacity.creature     Description of the number of creatures the vehicle can carry.
 * @property {number} attributes.capacity.cargo        Cargo carrying capacity measured in tons.
 * @property {object} traits
 * @property {string} traits.dimensions                Width and length of the vehicle.
 * @property {object} cargo                            Details on this vehicle's crew and cargo capacities.
 * @property {PassengerData[]} cargo.crew              Creatures responsible for operating the vehicle.
 * @property {PassengerData[]} cargo.passengers        Creatures just takin' a ride.
 * @property {SourceData} source                       Adventure or sourcebook where this vehicle originated.
 */
class VehicleData extends CommonTemplate {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _systemType = "vehicle";

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      vehicleType: new StringField$j({ required: true, initial: "water", label: "DND5E.VehicleType" }),
      attributes: new SchemaField$d({
        ...AttributesFields.common,
        ac: new SchemaField$d({
          ...AttributesFields.armorClass,
          motionless: new StringField$j({ required: true, label: "DND5E.ArmorClassMotionless" })
        }, { label: "DND5E.ArmorClass" }),
        hp: new SchemaField$d({
          value: new NumberField$a({
            nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsCurrent"
          }),
          max: new NumberField$a({
            nullable: true, integer: true, min: 0, initial: null, label: "DND5E.HitPointsMax"
          }),
          temp: new NumberField$a({ integer: true, initial: 0, min: 0, label: "DND5E.HitPointsTemp" }),
          tempmax: new NumberField$a({
            integer: true, initial: 0, label: "DND5E.HitPointsTempMax", hint: "DND5E.HitPointsTempMaxHint"
          }),
          dt: new NumberField$a({
            required: true, integer: true, min: 0, label: "DND5E.DamageThreshold"
          }),
          mt: new NumberField$a({
            required: true, integer: true, min: 0, label: "DND5E.VehicleMishapThreshold"
          })
        }, {label: "DND5E.HitPoints"}),
        actions: new SchemaField$d({
          stations: new BooleanField$8({ required: true, label: "DND5E.VehicleActionStations" }),
          value: new NumberField$a({
            required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.VehicleActionMax"
          }),
          thresholds: new SchemaField$d({
            2: new NumberField$a({
              required: true, integer: true, min: 0, label: "DND5E.VehicleActionThresholdsFull"
            }),
            1: new NumberField$a({
              required: true, integer: true, min: 0, label: "DND5E.VehicleActionThresholdsMid"
            }),
            0: new NumberField$a({
              required: true, integer: true, min: 0, label: "DND5E.VehicleActionThresholdsMin"
            })
          }, {label: "DND5E.VehicleActionThresholds"})
        }, {label: "DND5E.VehicleActions"}),
        capacity: new SchemaField$d({
          creature: new StringField$j({ required: true, label: "DND5E.VehicleCreatureCapacity" }),
          cargo: new NumberField$a({
            required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.VehicleCargoCapacity"
          })
        }, { label: "DND5E.VehicleCargoCrew" })
      }, { label: "DND5E.Attributes" }),
      details: new SchemaField$d(DetailsField.common, { label: "DND5E.Details" }),
      source: new SourceField(),
      traits: new SchemaField$d({
        ...TraitsField.common,
        size: new StringField$j({ required: true, initial: "lg", label: "DND5E.Size" }),
        di: new DamageTraitField({}, { label: "DND5E.DamImm", initialValue: ["poison", "psychic"] }),
        ci: new SimpleTraitField({}, { label: "DND5E.ConImm", initialValue: [
          "blinded", "charmed", "deafened", "frightened", "paralyzed",
          "petrified", "poisoned", "stunned", "unconscious"
        ] }),
        dimensions: new StringField$j({ required: true, label: "DND5E.Dimensions" })
      }, { label: "DND5E.Traits" }),
      cargo: new SchemaField$d({
        crew: new ArrayField$8(makePassengerData(), { label: "DND5E.VehicleCrew" }),
        passengers: new ArrayField$8(makePassengerData(), { label: "DND5E.VehiclePassengers" })
      }, { label: "DND5E.VehicleCrewPassengers" })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    AttributesFields._migrateInitiative(source.attributes);
    VehicleData.#migrateSource(source);
  }

  /* -------------------------------------------- */

  /**
   * Convert source string into custom object & move to top-level.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSource(source) {
    let custom;
    if ( ("details" in source) && ("source" in source.details) ) {
      if ( foundry.utils.getType(source.details?.source) === "string" ) custom = source.details.source;
      else source.source = source.details.source;
    }
    if ( custom ) source.source = { custom };
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.attributes.prof = 0;
    this.attributes.ac.calc = "flat";
    AttributesFields.prepareBaseArmorClass.call(this);
    AttributesFields.prepareBaseEncumbrance.call(this);
    SourceField.shimActor.call(this);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    const rollData = this.parent.getRollData({ deterministic: true });
    const { originalSaves } = this.parent.getOriginalStats();

    this.prepareAbilities({ rollData, originalSaves });
    AttributesFields.prepareArmorClass.call(this, rollData);
    AttributesFields.prepareEncumbrance.call(this, rollData, { validateItem: item =>
      (item.flags.dnd5e?.vehicleCargo === true) || !["weapon", "equipment"].includes(item.type)
    });
    AttributesFields.prepareHitPoints.call(this, this.attributes.hp);
    AttributesFields.prepareInitiative.call(this, rollData);
    AttributesFields.prepareMovement.call(this);
    SourceField.prepareData.call(this.source, this.parent._stats?.compendiumSource ?? this.parent.uuid);
    TraitsField.prepareResistImmune.call(this);
  }
}

/* -------------------------------------------- */

/**
 * Data structure for an entry in a vehicle's crew or passenger lists.
 *
 * @typedef {object} PassengerData
 * @property {string} name      Name of individual or type of creature.
 * @property {number} quantity  How many of this creature are onboard?
 */

/**
 * Produce the schema field for a simple trait.
 * @param {object} schemaOptions  Options passed to the outer schema.
 * @returns {PassengerData}
 */
function makePassengerData(schemaOptions={}) {
  return new SchemaField$d({
    name: new StringField$j({required: true, label: "DND5E.VehiclePassengerName"}),
    quantity: new NumberField$a({
      required: true, nullable: false, integer: true, initial: 0, min: 0, label: "DND5E.VehiclePassengerQuantity"
    })
  }, schemaOptions);
}

const config$3 = {
  character: CharacterData,
  group: GroupActor,
  npc: NPCData,
  vehicle: VehicleData
};

var _module$9 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AttributesFields: AttributesFields,
  CharacterData: CharacterData,
  CommonTemplate: CommonTemplate,
  CreatureTemplate: CreatureTemplate,
  DetailsFields: DetailsField,
  GroupData: GroupActor,
  GroupSystemFlags: GroupSystemFlags,
  NPCData: NPCData,
  TraitsFields: TraitsField,
  VehicleData: VehicleData,
  config: config$3
});

var _module$8 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  AbilityScoreImprovementConfigurationData: AbilityScoreImprovementConfigurationData,
  AbilityScoreImprovementValueData: AbilityScoreImprovementValueData,
  BaseAdvancement: BaseAdvancement,
  ItemChoiceConfigurationData: ItemChoiceConfigurationData,
  ItemChoiceValueData: ItemChoiceValueData,
  ItemGrantConfigurationData: ItemGrantConfigurationData,
  SizeConfigurationData: SizeConfigurationData,
  SizeValueData: SizeValueData,
  SpellConfigurationData: SpellConfigurationData,
  SubclassValueData: SubclassValueData,
  TraitConfigurationData: TraitConfigurationData,
  TraitValueData: TraitValueData,
  scaleValue: scaleValue
});

const { StringField: StringField$i } = foundry.data.fields;

/**
 * @import ActivationsData from "./fields/activations-field.mjs";
 * @import { ActorDeltasData } from "./fields/deltas-field.mjs";
 */

/**
 * Data stored in a rest chat message.
 *
 * @property {ActivationsData} activations  Activities that can be used after this rest, stored as relative UUIDs.
 * @property {ActorDeltasData} deltas       Actor/item recovery from this turn change.
 * @property {string} type                  Type of rest performed.
 */
class RestMessageData extends ChatMessageDataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      activations: new ActivationsField(),
      deltas: new ActorDeltasField(),
      type: new StringField$i()
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    template: "systems/dnd5e/templates/chat/rest-card.hbs"
  }, { inplace: false }));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The actor for the chat message.
   * @type {Actor5e}
   */
  get actor() {
    return this.parent.getAssociatedActor();
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext() {
    const context = {
      actor: this.actor,
      content: await TextEditor.enrichHTML(this.parent.content, { rollData: this.parent.getRollData() })
    };

    if ( context.actor?.isOwner ) {
      context.activities = ActivationsField.processActivations.call(this.activations, this.actor);
      context.deltas = ActorDeltasField.processDeltas.call(this.deltas, this.actor, this.parent.rolls);
    }

    return context;
  }
}

const { DocumentIdField: DocumentIdField$2, SchemaField: SchemaField$c, SetField: SetField$9, StringField: StringField$h } = foundry.data.fields;

/**
 * @import { ActorDeltasData } from "./fields/deltas-field.mjs";
 */

/**
 * Data stored in a combat turn chat message.
 *
 * @property {ActivationsData} activations  Activities that can be used with these periods, stored as relative UUIDs.
 * @property {ActorDeltasData} deltas       Actor/item recovery from this turn change.
 * @property {object} origin
 * @property {string} origin.combat         ID of the triggering combat.
 * @property {string} origin.combatant      ID of the relevant combatant within the combat.
 * @property {Set<string>} periods          Combat state change that triggered this message.
 */
class TurnMessageData extends ChatMessageDataModel {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static defineSchema() {
    return {
      activations: new ActivationsField(),
      deltas: new ActorDeltasField(),
      origin: new SchemaField$c({
        combat: new DocumentIdField$2({ nullable: false, required: true }),
        combatant: new DocumentIdField$2({ nullable: false, required: true })
      }),
      trigger: new SetField$9(new StringField$h())
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    template: "systems/dnd5e/templates/chat/turn-card.hbs"
  }, { inplace: false }));

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The actor belonging to the combatant.
   * @type {Actor5e}
   */
  get actor() {
    return this.combatant?.actor ?? this.parent.getAssociatedActor();
  }

  /* -------------------------------------------- */

  /**
   * The combat during which this message was triggered.
   * @type {Combat5e}
   */
  get combat() {
    return game.combats.get(this.origin.combat);
  }

  /* -------------------------------------------- */

  /**
   * The combatant to whom this message applies.
   * @type {Combatant5e}
   */
  get combatant() {
    return this.combat?.combatants.get(this.origin.combatant);
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @override */
  async _prepareContext(options) {
    const context = {
      actor: this.actor,
      combat: this.combat,
      combatant: this.combatant
    };

    if ( context.actor?.isOwner ) {
      context.activities = ActivationsField.processActivations.call(this.activations, this.actor);
      context.deltas = ActorDeltasField.processDeltas.call(this.deltas, this.actor, this.parent.rolls);
    }

    return context;
  }
}

var _module$7 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivationsField: ActivationsField,
  ActorDeltasField: ActorDeltasField,
  IndividualDeltaField: IndividualDeltaField
});

const config$2 = {
  rest: RestMessageData,
  turn: TurnMessageData
};

var _module$6 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  TurnMessageData: TurnMessageData,
  config: config$2,
  fields: _module$7
});

var _module$5 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  Items5e: Items5e
});

const { ArrayField: ArrayField$7 } = foundry.data.fields;

/**
 * Data definition for Background items.
 * @mixes ItemDescriptionTemplate
 * @mixes StartingEquipmentTemplate
 *
 * @property {object[]} advancement  Advancement objects for this background.
 */
class BackgroundData extends ItemDataModel.mixin(ItemDescriptionTemplate, StartingEquipmentTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      advancement: new ArrayField$7(new AdvancementField(), {label: "DND5E.AdvancementTitle"})
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    singleton: true
  }, {inplace: false}));

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: context.itemType }];
    context.singleDescription = true;
    context.parts = ["dnd5e.details-background", "dnd5e.details-starting-equipment"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( (game.user.id !== userId) || this.parent.actor?.type !== "character" ) return;
    this.parent.actor.update({"system.details.background": this.parent.id});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preDelete(options, user) {
    if ( (await super._preDelete(options, user)) === false ) return false;
    if ( this.parent.actor?.type !== "character" ) return;
    await this.parent.actor.update({"system.details.background": null});
  }
}

const { BooleanField: BooleanField$7, NumberField: NumberField$9, SchemaField: SchemaField$b, SetField: SetField$8, StringField: StringField$g } = foundry.data.fields;

/**
 * Data definition for Consumable items.
 * @mixes ActivitiesTemplate
 * @mixes ItemDescriptionTemplate
 * @mixes ItemTypeTemplate
 * @mixes IdentifiableTemplate
 * @mixes PhysicalItemTemplate
 * @mixes EquippableItemTemplate
 *
 * @property {object} damage
 * @property {DamageData} damage.base    Damage caused by this ammunition.
 * @property {string} damage.replace     Should ammunition damage replace the base weapon's damage?
 * @property {number} magicalBonus       Magical bonus added to attack & damage rolls by ammunition.
 * @property {Set<string>} properties    Ammunition properties.
 * @property {object} uses
 * @property {boolean} uses.autoDestroy  Should this item be destroyed when it runs out of uses.
 */
class ConsumableData extends ItemDataModel.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.CONSUMABLE", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      type: new ItemTypeField({ baseItem: false }, { label: "DND5E.ItemConsumableType" }),
      damage: new SchemaField$b({
        base: new DamageField(),
        replace: new BooleanField$7()
      }),
      magicalBonus: new NumberField$9({ min: 0, integer: true }),
      properties: new SetField$8(new StringField$g()),
      uses: new UsesField({
        autoDestroy: new BooleanField$7({ required: true })
      })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    inventoryItem: true,
    inventoryOrder: 300
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemConsumableType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.consumableTypes,
          keyPath: "system.type.value"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("consumable")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    ConsumableData.#migrateDamage(source);
    ConsumableData.#migratePropertiesData(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate weapon damage from old parts.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateDamage(source) {
    if ( "base" in (source.damage ?? {}) ) return;
    const systemData = { system: { scaling: { mode: "none" } } };
    if ( source.damage?.parts?.[0] ) {
      source.damage.base = BaseActivityData.transformDamagePartData(systemData, source.damage.parts.shift());
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the properties object into a set.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migratePropertiesData(source) {
    if ( foundry.utils.getType(source.properties) !== "Object" ) return;
    source.properties = filteredKeys(source.properties);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    ActivitiesTemplate._applyActivityShims.call(this);
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    if ( !this.type.value ) return;
    const config = CONFIG.DND5E.consumableTypes[this.type.value];
    if ( config ) {
      this.type.label = config.subtypes?.[this.type.subtype] ?? config.label;
    } else {
      this.type.label = game.i18n.localize(CONFIG.Item.typeLabels.consumable);
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.type.label, this.parent.labels.activation],
      uses: this.hasLimitedUses ? this.getUsesData() : null,
      quantity: this.quantity
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];
    context.damageTypes = Object.entries(CONFIG.DND5E.damageTypes).map(([value, { label }]) => {
      return {
        value, label,
        selected: context.source.damage.base.types.includes?.(value) ?? context.source.damage.base.types.has(value)
      };
    });
    context.denominationOptions = [
      { value: "", label: "" },
      { rule: true },
      ...CONFIG.DND5E.dieSteps.map(value => ({ value, label: `d${value}` }))
    ];
    context.parts = ["dnd5e.details-consumable", "dnd5e.field-uses"];
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      this.hasLimitedUses ? `${this.uses.value}/${this.uses.max} ${game.i18n.localize("DND5E.Charges")}` : null,
      this.priceLabel
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get _typeAbilityMod() {
    if ( this.type.value !== "scroll" ) return null;
    return this.parent?.actor?.system.attributes.spellcasting || "int";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.consumableTypes;
  }

  /* -------------------------------------------- */

  /**
   * Does this item have base damage defined in `damage.base` to offer to an activity?
   * @type {boolean}
   */
  get offersBaseDamage() {
    return this.type.value === "ammo";
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    const isProficient = this.parent?.actor?.getFlag("dnd5e", "tavernBrawlerFeat");
    return isProficient ? 1 : 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get validProperties() {
    const valid = super.validProperties;
    if ( this.type.value === "ammo" ) Object.entries(CONFIG.DND5E.itemProperties).forEach(([k, v]) => {
      if ( v.isPhysical ) valid.add(k);
      valid.add("ret");
    });
    else if ( this.type.value === "scroll" ) CONFIG.DND5E.validProperties.spell
      .filter(p => p !== "material").forEach(p => valid.add(p));
    return valid;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async getCraftCost(options={}) {
    const { days, gold } = await super.getCraftCost(options);
    const { consumable, magic } = CONFIG.DND5E.crafting;
    const { rarity } = this;
    if ( !this.properties.has("mgc") || !(rarity in magic) ) return { days, gold };
    const costs = magic[rarity];
    return {
      days: Math.floor(costs.days * consumable.days),
      gold: Math.floor(costs.gold * consumable.gold)
    };
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  getRollData(...options) {
    const data = super.getRollData(...options);
    const spellLevel = this.parent.getFlag("dnd5e", "spellLevel");
    if ( spellLevel ) data.item.level = spellLevel.value ?? spellLevel.base;
    return data;
  }
}

const { ArrayField: ArrayField$6, BooleanField: BooleanField$6, DocumentUUIDField, NumberField: NumberField$8, SchemaField: SchemaField$a, StringField: StringField$f } = foundry.data.fields;

/**
 * @typedef FacilityOccupants
 * @property {string[]} value  A list of Actor UUIDs assigned to the facility.
 * @property {number} max      The facility's maximum occupant capacity.
 */

/**
 * The data definition for Facility items.
 * @mixes ActivitiesTemplate
 * @mixes ItemDescriptionTemplate
 *
 * @property {object} building
 * @property {boolean} building.built             Whether the facility has been fully built. Only applicable to basic
 *                                                facilities.
 * @property {string} building.size               The target size for the facility to be built at.
 * @property {object} craft
 * @property {string} craft.item                  The Item the facility is currently crafting.
 * @property {number} craft.quantity              The number of Items being crafted.
 * @property {FacilityOccupants} defenders        The facility's configured defenders.
 * @property {boolean} disabled                   Whether the facility is currently disabled.
 * @property {boolean} enlargeable                Whether the facility is capable of being enlarged.
 * @property {boolean} free                       Whether the facility counts towards the character's maximum special
 *                                                facility cap.
 * @property {FacilityOccupants} hirelings        The facility's configured hirelings.
 * @property {number} level                       The minimum level required to build this facility.
 * @property {string} order                       The order type associated with this facility.
 * @property {object} progress
 * @property {number} progress.value              The number of days' progress made towards completing the order.
 * @property {number} progress.max                The number of days required to complete the order.
 * @property {string} progress.order              The order that is currently being executed.
 * @property {string} size                        The size category of the facility.
 * @property {object} trade
 * @property {FacilityOccupants} trade.creatures  The trade facility's stocked creatures.
 * @property {object} trade.pending
 * @property {string[]} trade.pending.creatures   Creatures being bought or sold.
 * @property {"buy"|"sell"} trade.pending.operation  The type of trade operation that was executed this turn.
 * @property {boolean} trade.pending.stocked      Whether the inventory will be fully stocked when the order completes.
 * @property {number} trade.pending.value         The base value transacted during the trade operation this turn.
 * @property {number} trade.profit                The trade facility's profit factor as a percentage.
 * @property {object} trade.stock
 * @property {boolean} trade.stock.stocked        Whether the facility is fully stocked.
 * @property {number} trade.stock.value           The value of the currently stocked goods.
 * @property {number} trade.stock.max             The maximum value of goods this facility can stock.
 */
class FacilityData extends ItemDataModel.mixin(ActivitiesTemplate, ItemDescriptionTemplate) {
  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.FACILITY", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      building: new SchemaField$a({
        built: new BooleanField$6({ required: true }),
        size: new StringField$f({ initial: "cramped", blank: false, nullable: false, required: true })
      }),
      craft: new SchemaField$a({
        // TODO: Add type constraint when v12 support is dropped.
        item: new DocumentUUIDField(),
        quantity: new NumberField$8({ required: true, integer: true, positive: true, initial: 1, nullable: false })
      }),
      defenders: new SchemaField$a({
        value: new ArrayField$6(new DocumentUUIDField({ type: "Actor" })),
        max: new NumberField$8({ required: true, integer: true, positive: true })
      }),
      disabled: new BooleanField$6({ required: true }),
      enlargeable: new BooleanField$6({ required: true }),
      free: new BooleanField$6({ required: true }),
      hirelings: new SchemaField$a({
        value: new ArrayField$6(new DocumentUUIDField({ type: "Actor" })),
        max: new NumberField$8({ required: true, integer: true, positive: true })
      }),
      level: new NumberField$8({ required: true, integer: true, positive: true, initial: 5 }),
      order: new StringField$f({ required: true }),
      progress: new SchemaField$a({
        value: new NumberField$8({ required: true, integer: true, min: 0, nullable: false, initial: 0 }),
        max: new NumberField$8({ required: true, integer: true, positive: true }),
        order: new StringField$f({ required: true })
      }),
      size: new StringField$f({ initial: "cramped", blank: false, nullable: false, required: true }),
      trade: new SchemaField$a({
        creatures: new SchemaField$a({
          value: new ArrayField$6(new DocumentUUIDField()),
          max: new NumberField$8({ required: true, integer: true, positive: true })
        }),
        pending: new SchemaField$a({
          creatures: new ArrayField$6(new DocumentUUIDField()),
          operation: new StringField$f({ required: true, nullable: true, options: ["buy", "sell"], initial: null }),
          stocked: new BooleanField$6({ required: true }),
          value: new NumberField$8({ required: true, min: 0, integer: true })
        }),
        profit: new NumberField$8({ required: true, min: 0, integer: true }),
        stock: new SchemaField$a({
          stocked: new BooleanField$6({ required: true }),
          value: new NumberField$8({ required: true, min: 0, integer: true }),
          max: new NumberField$8({ required: true, integer: true, positive: true })
        })
      }),
      type: new ItemTypeField({ value: "basic", baseItem: false })
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    const { basic, special } = CONFIG.DND5E.facilities.advancement;
    const min = Math.min(...Object.keys(basic), ...Object.keys(special));
    return new Map([
      ["level", {
        label: "DND5E.FACILITY.FIELDS.level.label",
        type: "range",
        config: {
          keyPath: "system.level",
          min,
          max: CONFIG.DND5E.maxLevel
        }
      }],
      ["type", {
        label: "DND5E.FACILITY.FIELDS.type.value.label",
        type: "set",
        config: {
          choices: Object.fromEntries(Object.entries(CONFIG.DND5E.facilities.types).map(([k, v]) => [k, v.label])),
          keyPath: "system.type.value"
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Create an ephemeral Order activity.
   * @param {string} id     The static ID string for the order. Will have staticID called on it.
   * @param {string} order  The order.
   * @protected
   */
  _createOrderActivity(id, order) {
    const activity = new OrderActivity({
      order,
      _id: staticID(id),
      name: game.i18n.format("DND5E.FACILITY.Order.Issue", {
        order: game.i18n.localize(`DND5E.FACILITY.Orders.${order}.inf`)
      })
    }, { parent: this.parent });
    this.activities.set(activity.id, activity);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    super.prepareBaseData();

    if ( this.type.value === "basic" ) this.enlargeable = this.building.built;
    else this.building.built = true;

    // Activities
    if ( (this.type.value === "special") && this.order ) this._createOrderActivity("dnd5eFacOrder", this.order);
    if ( this.enlargeable ) this._createOrderActivity("dnd5eFacEnlarge", "enlarge");
    if ( !this.building.built ) this._createOrderActivity("dnd5eFacBuild", "build");

    // TODO: Allow order activities to be user-creatable and configurable to avoid having to hard-code this.
    if ( this.type.subtype === "garden" ) this._createOrderActivity("dnd5eFacChange", "change");
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();

    // Type
    const config = CONFIG.DND5E.facilities.types[this.type.value];
    this.type.label = config?.subtypes?.[this.type.subtype] ?? config?.label;

    // Price
    if ( this.type.value === "basic" ) {
      const { value, days } = CONFIG.DND5E.facilities.sizes[this.size];
      this.price = { value, days, denomination: "gp" };
    }

    // Squares
    this.squares = CONFIG.DND5E.facilities.sizes[this.size].squares;

    // Progress
    if ( this.progress.max ) {
      const { value, max } = this.progress;
      this.progress.value = Math.clamp(value, 0, max);
      this.progress.pct = Math.round((this.progress.value / max) * 100);
    }

    if ( this.disabled ) this.progress.order = "repair";

    // Labels
    const labels = this.parent.labels ??= {};
    if ( this.order ) labels.order = game.i18n.localize(`DND5E.FACILITY.Orders.${this.order}.present`);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.facilitySubtypes = CONFIG.DND5E.facilities.types[this.type.value]?.subtypes ?? {};
    context.singleDescription = true;
    context.subtitles = [
      { label: this.type.label },
      { label: CONFIG.DND5E.facilities.sizes[this.size].label }
    ];
    context.parts = ["dnd5e.details-facility"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    const actor = this.parent?.parent;
    if ( !actor ) return;
    const { basic } = CONFIG.DND5E.facilities.advancement;
    const [, available = 0] = Object.entries(basic).reverse()
      .find(([level]) => level <= actor.system.details.level) ?? [];
    const existing = actor.itemTypes.facility.filter(f => f.system.type.value === "basic").length;
    const free = available - existing;
    if ( free > 0 ) this.updateSource({ "building.built": true });
  }
}

const { ArrayField: ArrayField$5, BooleanField: BooleanField$5, NumberField: NumberField$7, SchemaField: SchemaField$9, SetField: SetField$7, StringField: StringField$e } = foundry.data.fields;

/**
 * Data definition for Feature items.
 * @mixes ActivitiesTemplate
 * @mixes ItemDescriptionTemplate
 * @mixes ItemTypeTemplate
 *
 * @property {Advancement[]} advancement            Advancement objects for this feature.
 * @property {number} cover                         Amount of cover this feature affords to its crew on a vehicle.
 * @property {boolean} crewed                       Is this vehicle feature currently crewed?
 * @property {object} enchant
 * @property {string} enchant.max                   Maximum number of items that can have this enchantment.
 * @property {string} enchant.period                Frequency at which the enchantment can be swapped.
 * @property {object} prerequisites
 * @property {number} prerequisites.level           Character or class level required to choose this feature.
 * @property {boolean} prerequisites.repeatable     Can this item be selected more than once?
 * @property {Set<string>} properties               General properties of a feature item.
 * @property {string} requirements                  Actor details required to use this feature.
 */
class FeatData extends ItemDataModel.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, ItemTypeTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.FEATURE", "DND5E.ENCHANTMENT", "DND5E.Prerequisites", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      advancement: new ArrayField$5(new AdvancementField(), { label: "DND5E.AdvancementTitle" }),
      cover: new NumberField$7({ min: 0, max: 1 }),
      crewed: new BooleanField$5(),
      enchant: new SchemaField$9({
        max: new FormulaField({ deterministic: true }),
        period: new StringField$e()
      }),
      prerequisites: new SchemaField$9({
        level: new NumberField$7({ integer: true, min: 0 }),
        repeatable: new BooleanField$5()
      }),
      properties: new SetField$7(new StringField$e()),
      requirements: new StringField$e({ required: true, nullable: true }),
      type: new ItemTypeField({ baseItem: false })
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["category", {
        label: "DND5E.Item.Category.Label",
        type: "set",
        config: {
          choices: CONFIG.DND5E.featureTypes,
          keyPath: "system.type.value"
        }
      }],
      ["subtype", {
        label: "DND5E.ItemFeatureType",
        type: "set",
        config: {
          choices: Object.values(CONFIG.DND5E.featureTypes).reduce((obj, config) => {
            for ( const [key, label] of Object.entries(config.subtypes ?? {}) ) obj[key] = label;
            return obj;
          }, {}),
          keyPath: "system.type.subtype"
        }
      }],
      ["properties", this.compendiumBrowserPropertiesFilter("feat")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    ActivitiesTemplate._applyActivityShims.call(this);
    super.prepareDerivedData();
    this.prepareDescriptionData();

    if ( this.type.value ) {
      const config = CONFIG.DND5E.featureTypes[this.type.value];
      if ( config ) this.type.label = config.subtypes?.[this.type.subtype] ?? null;
      else this.type.label = game.i18n.localize(CONFIG.Item.typeLabels.feat);
    }

    let label;
    const activation = this.activities.contents[0]?.activation.type;
    if ( activation === "legendary" ) label = game.i18n.localize("DND5E.LegendaryAction.Label");
    else if ( activation === "lair" ) label = game.i18n.localize("DND5E.LAIR.Action.Label");
    else if ( activation === "action" && this.hasAttack ) label = game.i18n.localize("DND5E.Attack");
    else if ( activation ) label = game.i18n.localize("DND5E.Action");
    else label = game.i18n.localize("DND5E.Passive");
    this.parent.labels ??= {};
    this.parent.labels.featType = label;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));

    const uses = this.uses;
    this.recharge ??= {};
    Object.defineProperty(this.recharge, "value", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "Recharge data has been merged into uses data. Recharge state can now be determined by checking"
          + " `system.uses.recovery` for a profile with a `period` of 'recharge', and checking its `formula` for the"
          + " recharge formula.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        return uses.period === "recharge" ? Number(uses.formula) : null;
      },
      configurable: true
    });
    Object.defineProperty(this.recharge, "charged", {
      get() {
        foundry.utils.logCompatibilityWarning(
          "Recharge data has been merged into uses data. Determining charged state can now be done by determining"
          + " whether `system.uses.value` is greater than `0`.",
          { since: "DnD5e 4.0", until: "DnD5e 4.4" }
        );
        return uses.value > 0;
      },
      configurable: true
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: [this.parent.labels.activation, this.parent.labels.recovery],
      uses: this.hasLimitedUses ? this.getUsesData() : null
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      { label: this.parent.labels.featType },
      { label: this.requirements, value: this._source.requirements, field: this.schema.getField("requirements"),
        placeholder: "DND5E.Requirements" }
    ];
    context.parts = ["dnd5e.details-feat", "dnd5e.field-uses"];
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    FeatData.#migrateEnchantment(source);
    ActivitiesTemplate.migrateActivities(source);
    FeatData.#migrateType(source);
    FeatData.#migrateRecharge(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate enchantment data format.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateEnchantment(source) {
    if ( foundry.utils.getType(source.enchantment?.items) !== "Object" ) return;
    const { items } = source.enchantment;
    source.enchant ??= {};
    if ( "max" in items ) source.enchant.max = items.max;
    if ( "period" in items ) source.enchant.period = items.period;
    delete source.enchantment.items;
  }

  /* -------------------------------------------- */

  /**
   * Ensure feats have a type object.
   * @param {object} source The candidate source data from which the model will be constructed.
   */
  static #migrateType(source) {
    if ( !("type" in source) ) return;
    if ( !source.type ) source.type = {value: "", subtype: ""};
  }

  /* -------------------------------------------- */

  /**
   * Migrate 0 values to null.
   * @param {object} source The candidate source data from which the model will be constructed.
   */
  static #migrateRecharge(source) {
    if ( !("recharge" in source) ) return;
    const value = source.recharge.value;
    if ( (value === 0) || (value === "") ) source.recharge.value = null;
    else if ( (typeof value === "string") && Number.isNumeric(value) ) source.recharge.value = Number(value);
    if ( source.recharge.charged === null ) source.recharge.charged = false;
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [this.requirements];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    return [this.requirements];
  }

  /* -------------------------------------------- */

  /**
   * Does this feature represent a group of individual enchantments (e.g. the "Infuse Item" feature stores data about
   * all of the character's infusions).
   * @type {boolean}
   */
  get isEnchantmentSource() {
    return CONFIG.DND5E.featureTypes[this.type?.value]?.subtypes?.[this.type?.subtype]
      && (this.type?.subtype in CONFIG.DND5E.featureTypes.enchantment.subtypes);
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    return 1;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    // Set type as "Monster Feature" if created directly on a NPC
    if ( (this.parent.actor?.type === "npc") && !foundry.utils.hasProperty(data, "system.type.value") ) {
      this.updateSource({ "type.value": "monster" });
    }
  }
}

const { SetField: SetField$6, StringField: StringField$d } = foundry.data.fields;

/**
 * Data definition for Loot items.
 * @mixes ItemDescriptionTemplate
 * @mixes ItemTypeTemplate
 * @mixes IdentifiableTemplate
 * @mixes PhysicalItemTemplate
 */
class LootData extends ItemDataModel.mixin(
  ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate, PhysicalItemTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      properties: new SetField$6(new StringField$d(), { label: "DND5E.ItemLootProperties" }),
      type: new ItemTypeField({ baseItem: false }, { label: "DND5E.ItemLootType" })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    inventoryItem: true,
    inventoryOrder: 600
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemLootType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.lootTypes,
          keyPath: "system.type.value"
        }
      }],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("loot")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    this.type.label = CONFIG.DND5E.lootTypes[this.type.value]?.label ?? game.i18n.localize(CONFIG.Item.typeLabels.loot);
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];
    context.parts = ["dnd5e.details-loot"];
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      this.weight ? `${this.weight.value} ${game.i18n.localize("DND5E.AbbreviationLbs")}` : null,
      this.priceLabel
    ];
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.lootTypes;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

var _module$4 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActivationField: ActivationField,
  CreatureTypeField: CreatureTypeField,
  CurrencyTemplate: CurrencyTemplate,
  DamageData: DamageData,
  DamageField: DamageField,
  DurationField: DurationField,
  MovementField: MovementField,
  RangeField: RangeField,
  RollConfigField: RollConfigField,
  SensesField: SensesField,
  SourceField: SourceField,
  TargetField: TargetField,
  UsesField: UsesField
});

const { ArrayField: ArrayField$4 } = foundry.data.fields;

/**
 * Data definition for Race items.
 * @mixes ItemDescriptionTemplate
 *
 * @property {object[]} advancement    Advancement objects for this race.
 * @property {MovementField} movement
 * @property {SensesField} senses
 * @property {CreatureType} type
 */
class RaceData extends ItemDataModel.mixin(ItemDescriptionTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      advancement: new ArrayField$4(new AdvancementField(), { label: "DND5E.AdvancementTitle" }),
      movement: new MovementField({}, { initialUnits: defaultUnits("length") }),
      senses: new SensesField({}, { initialUnits: defaultUnits("length") }),
      type: new CreatureTypeField({ swarm: false }, { initial: { value: "humanoid" } })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    singleton: true
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["hasDarkvision", {
        label: "DND5E.CompendiumBrowser.Filters.HasDarkvision",
        type: "boolean",
        createFilter: (filters, value, def) => {
          if ( value === 0 ) return;
          const filter = { k: "system.senses.darkvision", o: "gt", v: 0 };
          if ( value === 1 ) filters.push(filter);
          else filters.push({ o: "NOT", v: filter });
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Sheet labels for a race's movement.
   * @returns {Object<string>}
   */
  get movementLabels() {
    const units = this.movement.units || defaultUnits("length");
    return Object.entries(CONFIG.DND5E.movementTypes).reduce((obj, [k, label]) => {
      const value = this.movement[k];
      if ( value ) obj[k] = `${label} ${formatLength(value, units)}`;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * Sheet labels for a race's senses.
   * @returns {Object<string>}
   */
  get sensesLabels() {
    const units = this.senses.units || defaultUnits("length");
    return Object.entries(CONFIG.DND5E.senses).reduce((arr, [k, label]) => {
      const value = this.senses[k];
      if ( value ) arr.push(`${label} ${formatLength(value, units)}`);
      return arr;
    }, []).concat(splitSemicolons(this.senses.special));
  }

  /* -------------------------------------------- */

  /**
   * Sheet label for a race's creature type.
   * @returns {Object<string>}
   */
  get typeLabel() {
    return Actor5e.formatCreatureType(this.type);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: context.itemType }];
    context.singleDescription = true;
    context.info = [{
      label: "DND5E.CreatureType",
      classes: "info-sm",
      value: this.typeLabel,
      config: "type",
      tooltip: "DND5E.CreatureTypeTitle"
    },
    {
      label: "DND5E.Movement",
      classes: "info-sm info-grid",
      config: "movement",
      tooltip: "DND5E.MovementConfig",
      value: Object.entries(CONFIG.DND5E.movementTypes).reduce((str, [k, label]) => {
        const value = this.movement[k];
        if ( !value ) return str;
        return `${str}
          <span class="key">${label}</span>
          <span class="value">${value}</span>
        `;
      }, "")
    },
    {
      label: "DND5E.Senses",
      classes: "info-sm info-grid",
      config: "senses",
      tooltip: "DND5E.SensesConfig",
      value: Object.entries(CONFIG.DND5E.senses).reduce((str, [k, label]) => {
        const value = this.senses[k];
        if ( !value ) return str;
        return `${str}
          <span class="key">${label}</span>
          <span class="value">${value}</span>
        `;
      }, "")
    }];
    context.parts = ["dnd5e.details-species"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    if ( data._id || foundry.utils.hasProperty(data, "system.advancement") ) return;

    const toCreate = [
      { type: "AbilityScoreImprovement" }, { type: "Size" },
      { type: "Trait", configuration: { grants: ["languages:standard:common"] } }
    ];
    this.parent.updateSource({"system.advancement": toCreate.map(c => {
      const config = CONFIG.DND5E.advancementTypes[c.type];
      const cls = config.documentClass ?? config;
      return new cls(c, { parent: this.parent }).toObject();
    })});
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( (game.user.id !== userId) || !["character", "npc"].includes(this.parent.actor?.type) ) return;
    this.parent.actor.update({ "system.details.race": this.parent.id });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preDelete(options, user) {
    if ( (await super._preDelete(options, user)) === false ) return false;
    if ( !["character", "npc"].includes(this.parent.actor?.type) ) return;
    await this.parent.actor.update({ "system.details.race": null });
  }
}

const { ArrayField: ArrayField$3 } = foundry.data.fields;

/**
 * Data definition for Subclass items.
 * @mixes ItemDescriptionTemplate
 *
 * @property {object[]} advancement    Advancement objects for this subclass.
 * @property {string} classIdentifier  Identifier slug for the class with which this subclass should be associated.
 * @property {SpellcastingField} spellcasting  Details on subclass's spellcasting ability.
 */
class SubclassData extends ItemDataModel.mixin(ItemDescriptionTemplate) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      advancement: new ArrayField$3(new AdvancementField(), { label: "DND5E.AdvancementTitle" }),
      classIdentifier: new IdentifierField({
        required: true, label: "DND5E.ClassIdentifier", hint: "DND5E.ClassIdentifierHint"
      }),
      spellcasting: new SpellcastingField()
    });
  }

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["class", {
        label: "TYPES.Item.class",
        type: "set",
        config: {
          choices: dnd5e.registry.classes.choices,
          keyPath: "system.classIdentifier"
        }
      }],
      ["hasSpellcasting", {
        label: "DND5E.CompendiumBrowser.Filters.HasSpellcasting",
        type: "boolean",
        createFilter: (filters, value, def) => {
          if ( value === 0 ) return;
          const filter = { k: "system.spellcasting.progression", v: "none" };
          if ( value === -1 ) filters.push(filter);
          else filters.push({ o: "NOT", v: filter });
        }
      }]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareBaseData() {
    this.spellcasting.preparation.value = 0;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    super.prepareDerivedData();
    this.prepareDescriptionData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    SpellcastingField.prepareData.call(this, this.parent.getRollData({ deterministic: true }));
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [{ label: context.itemType }];
    context.singleDescription = true;
    context.parts = ["dnd5e.details-subclass", "dnd5e.details-spellcasting"];
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    const actor = this.parent.actor;
    if ( !actor || (userId !== game.user.id) ) return;
    if ( !actor.system.attributes?.spellcasting && this.parent.spellcasting?.ability ) {
      actor.update({ "system.attributes.spellcasting": this.parent.spellcasting.ability });
    }
  }
}

const { NumberField: NumberField$6, SetField: SetField$5, StringField: StringField$c } = foundry.data.fields;

/**
 * Data definition for Tool items.
 * @mixes ActivitiesTemplate
 * @mixes ItemDescriptionTemplate
 * @mixes ItemTypeTemplate
 * @mixes IdentifiableTemplate
 * @mixes PhysicalItemTemplate
 * @mixes EquippableItemTemplate
 *
 * @property {string} ability     Default ability when this tool is being used.
 * @property {string} chatFlavor  Additional text added to chat when this tool is used.
 * @property {number} proficient  Level of proficiency in this tool as defined in `DND5E.proficiencyLevels`.
 * @property {string} bonus       Bonus formula added to tool rolls.
 */
class ToolData extends ItemDataModel.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      type: new ItemTypeField({ subtype: false }, { label: "DND5E.ItemToolType" }),
      ability: new StringField$c({ required: true, blank: true, label: "DND5E.DefaultAbilityCheck" }),
      chatFlavor: new StringField$c({ required: true, label: "DND5E.ChatFlavor" }),
      proficient: new NumberField$6({
        required: true, initial: null, min: 0, max: 2, step: 0.5, label: "DND5E.ItemToolProficiency"
      }),
      properties: new SetField$5(new StringField$c(), { label: "DND5E.ItemToolProperties" }),
      bonus: new FormulaField({ required: true, label: "DND5E.ItemToolBonus" })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    inventoryItem: true,
    inventoryOrder: 400
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemToolType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.toolTypes,
          keyPath: "system.type.value"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("tool")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    ToolData.#migrateAbility(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the ability field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateAbility(source) {
    if ( Array.isArray(source.ability) ) source.ability = source.ability[0];
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    ActivitiesTemplate._applyActivityShims.call(this);
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    this.type.label = CONFIG.DND5E.toolTypes[this.type.value] ?? game.i18n.localize(CONFIG.Item.typeLabels.tool);
    this.type.identifier = CONFIG.DND5E.toolIds[this.type.baseItem];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: this.type.label,
      modifier: this.parent.parent?.system.tools?.[this.type.baseItem]?.total
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];
    context.parts = ["dnd5e.details-tool", "dnd5e.field-uses"];
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [CONFIG.DND5E.abilities[this.ability]?.label];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    return [CONFIG.DND5E.abilities[this.ability]?.label];
  }

  /* -------------------------------------------- */

  /**
   * Which ability score modifier is used by this item?
   * @type {string|null}
   */
  get abilityMod() {
    return this.ability || "int";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.toolTypes;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    if ( Number.isFinite(this.proficient) ) return this.proficient;
    const actor = this.parent.actor;
    if ( !actor ) return 0;
    if ( actor.type === "npc" ) return 1;
    const baseItemProf = actor.system.tools?.[this.type.baseItem];
    const categoryProf = actor.system.tools?.[this.type.value];
    return Math.max(baseItemProf?.value ?? 0, categoryProf?.value ?? 0);
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    if ( this.activities.size ) return;

    const activityData = new CONFIG.DND5E.activityTypes.check.documentClass({}, { parent: this.parent }).toObject();
    this.parent.updateSource({ [`system.activities.${activityData._id}`]: activityData });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

const { NumberField: NumberField$5, SchemaField: SchemaField$8, SetField: SetField$4, StringField: StringField$b } = foundry.data.fields;

/**
 * Data definition for Weapon items.
 * @mixes ActivitiesTemplate
 * @mixes ItemDescriptionTemplate
 * @mixes ItemTypeTemplate
 * @mixes IdentifiableTemplate
 * @mixes PhysicalItemTemplate
 * @mixes EquippableItemTemplate
 * @mixes MountableTemplate
 *
 * @property {object} ammunition
 * @property {string} ammunition.type       Type of ammunition fired by this weapon.
 * @property {object} armor
 * @property {number} armor.value           Siege or vehicle weapon's armor class.
 * @property {object} damage
 * @property {DamageData} damage.base       Weapon's base damage.
 * @property {DamageData} damage.versatile  Weapon's versatile damage.
 * @property {number} magicalBonus          Magical bonus added to attack & damage rolls.
 * @property {string} mastery               Mastery Property usable with this weapon.
 * @property {Set<string>} properties       Weapon's properties.
 * @property {number} proficient            Does the weapon's owner have proficiency?
 * @property {object} range
 * @property {number} range.value           Short range of the weapon.
 * @property {number} range.long            Long range of the weapon.
 * @property {number|null} range.reach      Reach of the weapon.
 * @property {string} range.units           Units used to measure the weapon's range and reach.
 */
class WeaponData extends ItemDataModel.mixin(
  ActivitiesTemplate, ItemDescriptionTemplate, IdentifiableTemplate, ItemTypeTemplate,
  PhysicalItemTemplate, EquippableItemTemplate, MountableTemplate
) {

  /* -------------------------------------------- */
  /*  Model Configuration                         */
  /* -------------------------------------------- */

  /** @override */
  static LOCALIZATION_PREFIXES = ["DND5E.WEAPON", "DND5E.VEHICLE.MOUNTABLE", "DND5E.RANGE", "DND5E.SOURCE"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      type: new ItemTypeField({value: "simpleM", subtype: false}, {label: "DND5E.ItemWeaponType"}),
      ammunition: new SchemaField$8({
        type: new StringField$b()
      }),
      armor: new SchemaField$8({
        value: new NumberField$5({ integer: true, min: 0 })
      }),
      damage: new SchemaField$8({
        base: new DamageField(),
        versatile: new DamageField()
      }),
      magicalBonus: new NumberField$5({min: 0, integer: true, label: "DND5E.MagicalBonus"}),
      mastery: new StringField$b(),
      properties: new SetField$4(new StringField$b(), {label: "DND5E.ItemWeaponProperties"}),
      proficient: new NumberField$5({
        required: true, min: 0, max: 1, integer: true, initial: null, label: "DND5E.ProficiencyLevel"
      }),
      range: new SchemaField$8({
        value: new NumberField$5({ min: 0 }),
        long: new NumberField$5({ min: 0 }),
        reach: new NumberField$5({ min: 0 }),
        units: new StringField$b()
      })
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static metadata = Object.freeze(foundry.utils.mergeObject(super.metadata, {
    enchantable: true,
    inventoryItem: true,
    inventoryOrder: 100
  }, {inplace: false}));

  /* -------------------------------------------- */

  /** @override */
  static get compendiumBrowserFilters() {
    return new Map([
      ["type", {
        label: "DND5E.ItemWeaponType",
        type: "set",
        config: {
          choices: CONFIG.DND5E.weaponTypes,
          keyPath: "system.type.value"
        }
      }],
      ["mastery", {
        label: "DND5E.WEAPON.Mastery.Label",
        type: "set",
        config: {
          choices: CONFIG.DND5E.weaponMasteries,
          keyPath: "system.mastery"
        }
      }],
      ["attunement", this.compendiumBrowserAttunementFilter],
      ...this.compendiumBrowserPhysicalItemFilters,
      ["properties", this.compendiumBrowserPropertiesFilter("weapon")]
    ]);
  }

  /* -------------------------------------------- */
  /*  Data Migrations                             */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivitiesTemplate.migrateActivities(source);
    WeaponData.#migrateDamage(source);
    WeaponData.#migratePropertiesData(source);
    WeaponData.#migrateProficient(source);
    WeaponData.#migrateReach(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate weapon damage from old parts.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateDamage(source) {
    if ( "base" in (source.damage ?? {}) ) return;
    const systemData = { system: { scaling: { mode: "none" } } };
    if ( source.damage?.parts?.[0] ) {
      source.damage.base = BaseActivityData.transformDamagePartData(systemData, source.damage.parts.shift());
      if ( source.damage.base.bonus === "@mod" ) source.damage.base.bonus = "";
    }
    if ( foundry.utils.getType(source.damage?.versatile) === "string" ) {
      source.damage.versatile = BaseActivityData.transformDamagePartData(systemData, [source.damage?.versatile, ""]);
      if ( source.damage.versatile.bonus === "@mod" ) source.damage.versatile.bonus = "";
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the properties object into a set.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migratePropertiesData(source) {
    if ( foundry.utils.getType(source.properties) !== "Object" ) return;
    source.properties = filteredKeys(source.properties);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the proficient field to convert boolean values.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateProficient(source) {
    if ( typeof source.proficient === "boolean" ) source.proficient = Number(source.proficient);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the range value to the reach field for melee weapons without the thrown property.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateReach(source) {
    if ( !source.properties || !source.range?.value || !source.type?.value
      || (source.range?.reach !== undefined) ) return;
    if ( (CONFIG.DND5E.weaponTypeMap[source.type.value] !== "melee") || source.properties.includes("thr") ) return;
    // Range of `0` or greater than `10` is always included, and so is range longer than `5` without reach property
    if ( (source.range.value === 0) || (source.range.value > 10)
      || (!source.properties.includes("rch") && (source.range.value > 5)) ) {
      source.range.reach = source.range.value;
    }
    source.range.value = null;
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    ActivitiesTemplate._applyActivityShims.call(this);
    super.prepareDerivedData();
    this.prepareDescriptionData();
    this.prepareIdentifiable();
    this.preparePhysicalData();
    this.type.label = CONFIG.DND5E.weaponTypes[this.type.value] ?? game.i18n.localize(CONFIG.Item.typeLabels.weapon);
    this.type.identifier = CONFIG.DND5E.weaponIds[this.type.baseItem];

    const labels = this.parent.labels ??= {};
    labels.armor = this.armor.value ? `${this.armor.value} ${game.i18n.localize("DND5E.AC")}` : "";
    labels.damage = this.damage.base.formula;
    labels.damageTypes = game.i18n.getListFormatter({ style: "narrow" }).format(
      Array.from(this.damage.base.types).map(t => CONFIG.DND5E.damageTypes[t]?.label).filter(t => t)
    );

    if ( this.attackType === "ranged" ) this.range.reach = null;
    else if ( this.range.reach === null ) {
      this.range.reach = convertLength(this.properties.has("rch") ? 10 : 5, "ft", this.range.units, { strict: false });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareFinalData() {
    this.prepareFinalActivityData(this.parent.getRollData({ deterministic: true }));
    this.prepareFinalEquippableData();

    const labels = this.parent.labels ??= {};
    const units = this.range.units ?? defaultUnits("length");
    if ( this.hasRange ) {
      const parts = [this.range.value, this.range.long !== this.range.value ? this.range.long : null].filter(_ => _);
      parts.push(formatLength(parts.pop(), units));
      labels.range = parts.filterJoin("/");
    }
    if ( this.range.reach ) {
      labels.reach = game.i18n.format("DND5E.RANGE.Formatted.Reach", { reach: formatLength(this.range.reach, units) });
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getFavoriteData() {
    return foundry.utils.mergeObject(await super.getFavoriteData(), {
      subtitle: CONFIG.DND5E.itemActionTypes[this.activities.contents[0]?.actionType],
      modifier: this.parent.labels.modifier,
      range: this.range
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async getSheetData(context) {
    context.subtitles = [
      { label: context.itemType },
      { label: this.type.label },
      ...this.physicalItemSheetFields
    ];
    context.info = [{
      label: "DND5E.ToHit",
      classes: "info-lg",
      value: dnd5e.utils.formatModifier(parseInt(this.parent.labels.modifier))
    }];
    if ( this.parent.labels.damages?.length ) {
      const config = { ...CONFIG.DND5E.damageTypes, ...CONFIG.DND5E.healingTypes };
      context.info.push({ value: this.parent.labels.damages.reduce((str, { formula, damageType }) => {
        const type = config[damageType];
        return `${str}
          <span class="formula">${formula}</span>
          ${type ? `<span class="damage-type" data-tooltip="${type.label}" aria-label="${type.label}">
            <dnd5e-icon src="${type.icon}"></dnd5e-icon>
          </span>` : ""}
        `;
      }, ""), classes: "info-grid damage" });
    }
    context.parts = ["dnd5e.details-weapon", "dnd5e.field-uses"];

    // Damage
    context.damageTypes = Object.entries(CONFIG.DND5E.damageTypes).map(([value, { label }]) => {
      return {
        value, label,
        selected: context.source.damage.base.types.includes?.(value) ?? context.source.damage.base.types.has(value)
      };
    });
    const makeDenominationOptions = placeholder => [
      { value: "", label: placeholder ? `d${placeholder}` : "" },
      { rule: true },
      ...CONFIG.DND5E.dieSteps.map(value => ({ value, label: `d${value}` }))
    ];
    context.denominationOptions = {
      base: makeDenominationOptions(),
      versatile: makeDenominationOptions(this.damage.base.denomination ? this.damage.base.steppedDenomination() : "")
    };
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Ammunition that can be used with this weapon.
   * @type {FormSelectOption[]}
   */
  get ammunitionOptions() {
    if ( !this.parent.actor || !this.properties.has("amm") ) return [];
    return this.parent.actor.itemTypes.consumable
      .filter(i => (i.system.type.value === "ammo")
        && (!this.ammunition?.type || (i.system.type.subtype === this.ammunition.type)))
      .map(item => ({
        item,
        value: item.id,
        label: `${item.name} (${item.system.quantity})`,
        disabled: !item.system.quantity
      }))
      .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang));
  }

  /* -------------------------------------------- */

  /**
   * Attack classification of this weapon.
   * @type {"weapon"|"unarmed"}
   */
  get attackClassification() {
    return CONFIG.DND5E.weaponClassificationMap[this.type.value] ?? "weapon";
  }

  /* -------------------------------------------- */

  /** @override */
  get attackModes() {
    const modes = [];

    // Thrown ranged weapons will just display the "Thrown" mode
    if ( !(this.properties.has("thr") && (this.attackType === "ranged")) ) {
      // Weapons without the "Two-Handed" property or with the "Versatile" property will have One-Handed attack
      if ( this.isVersatile || !this.properties.has("two") ) modes.push({
        value: "oneHanded", label: CONFIG.DND5E.attackModes.oneHanded.label
      });

      // Weapons with the "Two-Handed" property or with the "Versatile" property will have Two-Handed attack
      if ( this.isVersatile || this.properties.has("two") ) modes.push({
        value: "twoHanded", label: CONFIG.DND5E.attackModes.twoHanded.label
      });
    }

    const isLight = this.properties.has("lgt") || (this.parent.actor?.getFlag("dnd5e", "enhancedDualWielding")
      && ((this.attackType === "melee") && !this.properties.has("two")));

    // Weapons with the "Light" property will have Offhand attack
    // If player has the "Enhanced Dual Wielding" flag, then allow any melee weapon without the "Two-Handed" property
    if ( isLight ) modes.push({
      value: "offhand", label: CONFIG.DND5E.attackModes.offhand.label
    });

    // Weapons with the "Thrown" property will have Thrown attack
    if ( this.properties.has("thr") ) {
      if ( modes.length ) modes.push({ rule: true });
      modes.push({ value: "thrown", label: CONFIG.DND5E.attackModes.thrown.label });

      // Weapons with the "Thrown" & "Light" properties will have an Offhand Throw attack
      if ( isLight ) modes.push({
        value: "thrown-offhand", label: CONFIG.DND5E.attackModes["thrown-offhand"].label
      });
    }

    else if ( !this.attackType && ((this.range.value ?? 0) > (this.range.reach ?? 0)) ) {
      if ( modes.length ) modes.push({ rule: true });
      modes.push({ value: "ranged", label: CONFIG.DND5E.attackModes.ranged.label });
    }

    return modes;
  }

  /* -------------------------------------------- */

  /**
   * Attack type offered by this weapon.
   * @type {"melee"|"ranged"|null}
   */
  get attackType() {
    return CONFIG.DND5E.weaponTypeMap[this.type.value] ?? null;
  }

  /* -------------------------------------------- */

  /** @override */
  get availableAbilities() {
    const melee = CONFIG.DND5E.defaultAbilities.meleeAttack;
    const ranged = CONFIG.DND5E.defaultAbilities.rangedAttack;
    if ( this.properties.has("fin") ) return new Set([melee, ranged]);
    if ( !this.attackType ) return null;
    return new Set([this.attackType === "melee" ? melee : ranged]);
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed in chat.
   * @type {string[]}
   */
  get chatProperties() {
    return [
      this.type.label,
      this.isMountable ? (this.parent.labels?.armor ?? null) : null
    ];
  }

  /* -------------------------------------------- */

  /**
   * Properties displayed on the item card.
   * @type {string[]}
   */
  get cardProperties() {
    return [
      this.isMountable ? (this.parent.labels?.armor ?? null) : null
    ];
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  get _typeAbilityMod() {
    const availableAbilities = this.availableAbilities;
    if ( !availableAbilities ) return null;
    if ( availableAbilities.size === 1 ) return availableAbilities.first();
    const abilities = this.parent?.actor?.system.abilities ?? {};
    return availableAbilities.reduce((largest, ability) =>
      (abilities[ability]?.mod ?? -Infinity) > (abilities[largest]?.mod ?? -Infinity) ? ability : largest
    , availableAbilities.first());
  }

  /* -------------------------------------------- */

  /** @override */
  get criticalThreshold() {
    return this.parent?.actor?.flags.dnd5e?.weaponCriticalThreshold ?? Infinity;
  }

  /* -------------------------------------------- */

  /**
   * Is the range value relevant to this weapon?
   * @type {boolean}
   */
  get hasRange() {
    return (this.attackType === "ranged") || this.properties.has("thr");
  }

  /* -------------------------------------------- */

  /**
   * Is this item a separate large object like a siege engine or vehicle component that is
   * usually mounted on fixtures rather than equipped, and has its own AC and HP?
   * @type {boolean}
   */
  get isMountable() {
    return this.type.value === "siege";
  }

  /* -------------------------------------------- */

  /** @override */
  static get itemCategories() {
    return CONFIG.DND5E.weaponTypes;
  }

  /* -------------------------------------------- */

  /**
   * Does the Weapon implement a versatile damage roll as part of its usage?
   * @type {boolean}
   */
  get isVersatile() {
    return this.properties.has("ver");
  }

  /* -------------------------------------------- */

  /**
   * Mastery options that can be used when attacking with this weapon.
   * @type {FormSelectOption[]|null}
   */
  get masteryOptions() {
    if ( !this.parent.actor?.system.traits?.weaponProf?.mastery?.value.has(this.type.baseItem) || !this.mastery ) {
      return null;
    }
    const extras = [];
    for ( const mastery of this.parent.actor.system.traits.weaponProf.mastery.bonus ?? [] ) {
      if ( mastery === this.mastery ) continue;
      if ( !extras.length ) extras.push({ rule: true });
      extras.push({ value: mastery, label: CONFIG.DND5E.weaponMasteries[mastery]?.label ?? mastery });
    }
    return [
      { value: this.mastery, label: CONFIG.DND5E.weaponMasteries[this.mastery]?.label ?? this.mastery },
      ...extras
    ];
  }

  /* -------------------------------------------- */

  /**
   * Does this item have base damage defined in `damage.base` to offer to an activity?
   * @type {boolean}
   */
  get offersBaseDamage() {
    return true;
  }

  /* -------------------------------------------- */

  /**
   * The proficiency multiplier for this item.
   * @returns {number}
   */
  get proficiencyMultiplier() {
    if ( Number.isFinite(this.proficient) ) return this.proficient;
    const actor = this.parent.actor;
    if ( !actor ) return 0;
    if ( actor.type === "npc" ) return 1; // NPCs are always considered proficient with any weapon in their stat block.
    const config = CONFIG.DND5E.weaponProficienciesMap;
    const itemProf = config[this.type.value];
    const actorProfs = actor.system.traits?.weaponProf?.value ?? new Set();
    const natural = this.type.value === "natural";
    const improvised = (this.type.value === "improv") && !!actor.getFlag("dnd5e", "tavernBrawlerFeat");
    const isProficient = natural || improvised || actorProfs.has(itemProf) || actorProfs.has(this.type.baseItem);
    return Number(isProficient);
  }

  /* -------------------------------------------- */

  /**
   * Attack types that can be used with this item by default.
   * @type {Set<string>}
   */
  get validAttackTypes() {
    const types = new Set();
    const attackType = this.attackType;
    if ( (attackType === "melee") || (attackType === null) ) types.add("melee");
    if ( (attackType === "ranged") || this.properties.has("thr")
      || ((attackType === null) && this.range.value) ) types.add("ranged");
    return types;
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;
    await this.preCreateEquipped(data, options, user);
    if ( this.activities.size ) return;

    const activityData = new CONFIG.DND5E.activityTypes.attack.documentClass({}, { parent: this.parent }).toObject();
    this.parent.updateSource({ [`system.activities.${activityData._id}`]: activityData });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preUpdate(changed, options, user) {
    if ( (await super._preUpdate(changed, options, user)) === false ) return false;
    await this.preUpdateIdentifiable(changed, options, user);
  }
}

const { BooleanField: BooleanField$4, EmbeddedDataField: EmbeddedDataField$1, SchemaField: SchemaField$7, StringField: StringField$a } = foundry.data.fields;

/**
 * A field for storing enchantment data.
 */
class EnchantmentField extends EmbeddedDataField$1 {
  /**
   * Construct an enchantment field.
   * @param {object} [options={}]  Options to configure this field's behavior.
   */
  constructor(options={}) {
    super(EnchantmentData, foundry.utils.mergeObject({ required: false, nullable: true, initial: null }, options));
  }
}

/**
 * Data stored in "enchantment" flag on enchantment active effects.
 *
 * @typedef {object} EnchantmentProfile
 * @property {object} level
 * @property {number} level.min        Minimum level at which this profile can be used.
 * @property {number} level.max        Maximum level at which this profile can be used.
 * @property {object} riders
 * @property {string[]} riders.effect  IDs of other effects on this item that will be added with this enchantment.
 * @property {string[]} riders.item    UUIDs of items that will be added with this enchantment.
 */

/**
 * Data model for enchantment configuration.
 *
 * @property {string} classIdentifier             Class identifier that will be used to determine applicable level.
 * @property {object} items
 * @property {string} items.max                   Maximum number of items that can have this enchantment.
 * @property {string} items.period                Frequency at which the enchantment be swapped.
 * @property {object} restrictions
 * @property {boolean} restrictions.allowMagical  Allow enchantments to be applied to items that are already magical.
 * @property {string} restrictions.type           Item type to which this enchantment can be applied.
 */
class EnchantmentData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      classIdentifier: new IdentifierField(),
      items: new SchemaField$7({
        max: new FormulaField({deterministic: true}),
        period: new StringField$a()
      }),
      restrictions: new SchemaField$7({
        allowMagical: new BooleanField$4(),
        type: new StringField$a()
      })
    };
  }
}

/**
 * Error to throw when an item cannot be enchanted.
 */
class EnchantmentError extends Error {
  constructor(...args) {
    super(...args);
    this.name = "EnchantmentError";
  }
}

const {
  ArrayField: ArrayField$2, BooleanField: BooleanField$3, DocumentIdField: DocumentIdField$1, EmbeddedDataField, NumberField: NumberField$4, SchemaField: SchemaField$6, SetField: SetField$3, StringField: StringField$9
} = foundry.data.fields;

/**
 * A field for storing summons data.
 *
 * @param {object} [options={}]  Options to configure this field's behavior.
 */
class SummonsField extends EmbeddedDataField {
  constructor(options={}) {
    super(SummonsData, foundry.utils.mergeObject({ required: false, nullable: true, initial: null }, options));
  }
}

/**
 * Information for a single summoned creature.
 *
 * @typedef {object} SummonsProfile
 * @property {string} _id         Unique ID for this profile.
 * @property {string} count       Formula for the number of creatures to summon.
 * @property {string} cr          Formula for the CR of summoned creatures if in CR mode.
 * @property {object} level
 * @property {number} level.min   Minimum level at which this profile can be used.
 * @property {number} level.max   Maximum level at which this profile can be used.
 * @property {string} name        Display name for this profile if it differs from actor's name.
 * @property {Set<string>} types  Types of summoned creatures if in CR mode.
 * @property {string} uuid        UUID of the actor to summon if in default mode.
 */

/**
 * Data model for summons configuration.
 *
 * @property {object} bonuses
 * @property {string} bonuses.ac            Formula for armor class bonus on summoned actor.
 * @property {string} bonuses.hd            Formula for bonus hit dice to add to each summoned NPC.
 * @property {string} bonuses.hp            Formula for bonus hit points to add to each summoned actor.
 * @property {string} bonuses.attackDamage  Formula for bonus added to damage for attacks.
 * @property {string} bonuses.saveDamage    Formula for bonus added to damage for saving throws.
 * @property {string} bonuses.healing       Formula for bonus added to healing.
 * @property {string} classIdentifier       Class identifier that will be used to determine applicable level.
 * @property {Set<string>} creatureSizes    Set of creature sizes that will be set on summoned creature.
 * @property {Set<string>} creatureTypes    Set of creature types that will be set on summoned creature.
 * @property {object} match
 * @property {boolean} match.attacks        Match the to hit values on summoned actor's attack to the summoner.
 * @property {boolean} match.proficiency    Match proficiency on summoned actor to the summoner.
 * @property {boolean} match.saves          Match the save DC on summoned actor's abilities to the summoner.
 * @property {""|"cr"} mode                 Method of determining what type of creature is summoned.
 * @property {SummonsProfile[]} profiles    Information on creatures that can be summoned.
 * @property {boolean} prompt               Should the player be prompted to place the summons?
 */
class SummonsData extends foundry.abstract.DataModel {
  /** @inheritDoc */
  static defineSchema() {
    return {
      bonuses: new SchemaField$6({
        ac: new FormulaField({
          label: "DND5E.Summoning.Bonuses.ArmorClass.Label", hint: "DND5E.Summoning.Bonuses.ArmorClass.hint"
        }),
        hd: new FormulaField({
          label: "DND5E.Summoning.Bonuses.HitDice.Label", hint: "DND5E.Summoning.Bonuses.HitDice.hint"
        }),
        hp: new FormulaField({
          label: "DND5E.Summoning.Bonuses.HitPoints.Label", hint: "DND5E.Summoning.Bonuses.HitPoints.hint"
        }),
        attackDamage: new FormulaField({
          label: "DND5E.Summoning.Bonuses.Attack.Label", hint: "DND5E.Summoning.Bonuses.Attack.Hint"
        }),
        saveDamage: new FormulaField({
          label: "DND5E.Summoning.Bonuses.Saves.Label", hint: "DND5E.Summoning.Bonuses.Saves.Hint"
        }),
        healing: new FormulaField({
          label: "DND5E.Summoning.Bonuses.Healing.Label", hint: "DND5E.Summoning.Bonuses.Healing.Hint"
        })
      }),
      classIdentifier: new IdentifierField(),
      creatureSizes: new SetField$3(new StringField$9(), {
        label: "DND5E.Summoning.CreatureSizes.Label", hint: "DND5E.Summoning.CreatureSizes.Hint"
      }),
      creatureTypes: new SetField$3(new StringField$9(), {
        label: "DND5E.Summoning.CreatureTypes.Label", hint: "DND5E.Summoning.CreatureTypes.Hint"
      }),
      match: new SchemaField$6({
        attacks: new BooleanField$3({
          label: "DND5E.Summoning.Match.Attacks.Label", hint: "DND5E.Summoning.Match.Attacks.Hint"
        }),
        proficiency: new BooleanField$3({
          label: "DND5E.Summoning.Match.Proficiency.Label", hint: "DND5E.Summoning.Match.Proficiency.Hint"
        }),
        saves: new BooleanField$3({
          label: "DND5E.Summoning.Match.Saves.Label", hint: "DND5E.Summoning.Match.Saves.Hint"
        })
      }),
      mode: new StringField$9({label: "DND5E.Summoning.Mode.Label", hint: "DND5E.Summoning.Mode.Hint"}),
      profiles: new ArrayField$2(new SchemaField$6({
        _id: new DocumentIdField$1({initial: () => foundry.utils.randomID()}),
        count: new FormulaField(),
        cr: new FormulaField({deterministic: true}),
        level: new SchemaField$6({
          min: new NumberField$4({integer: true, min: 0}),
          max: new NumberField$4({integer: true, min: 0})
        }),
        name: new StringField$9(),
        types: new SetField$3(new StringField$9()),
        uuid: new StringField$9()
      })),
      prompt: new BooleanField$3({
        initial: true, label: "DND5E.Summoning.Prompt.Label", hint: "DND5E.Summoning.Prompt.Hint"
      })
    };
  }
}

const { ArrayField: ArrayField$1, BooleanField: BooleanField$2, NumberField: NumberField$3, SchemaField: SchemaField$5, StringField: StringField$8 } = foundry.data.fields;

/**
 * @deprecated since 4.0, targeted for removal in 4.4
 * @mixin
 */
class ActionTemplate extends ItemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    foundry.utils.logCompatibilityWarning(
      "The `ActionTemplate` data model has been deprecated in favor of `ActivitiesTemplate`.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
    );
    return {
      ability: new StringField$8({required: true, nullable: true, initial: null, label: "DND5E.AbilityModifier"}),
      actionType: new StringField$8({required: true, nullable: true, initial: null, label: "DND5E.ItemActionType"}),
      attack: new SchemaField$5({
        bonus: new FormulaField({required: true, label: "DND5E.ItemAttackBonus"}),
        flat: new BooleanField$2({label: "DND5E.ItemAttackFlat"})
      }),
      chatFlavor: new StringField$8({required: true, label: "DND5E.ChatFlavor"}),
      critical: new SchemaField$5({
        threshold: new NumberField$3({
          required: true, integer: true, initial: null, positive: true, label: "DND5E.ItemCritThreshold"
        }),
        damage: new FormulaField({required: true, label: "DND5E.ItemCritExtraDamage"})
      }),
      damage: new SchemaField$5({
        parts: new ArrayField$1(new ArrayField$1(new StringField$8({nullable: true})), {required: true}),
        versatile: new FormulaField({required: true, label: "DND5E.VersatileDamage"})
      }, {label: "DND5E.Damage"}),
      enchantment: new EnchantmentField(),
      formula: new FormulaField({required: true, label: "DND5E.OtherFormula"}),
      save: new SchemaField$5({
        ability: new StringField$8({required: true, blank: true, label: "DND5E.Ability"}),
        dc: new NumberField$3({required: true, min: 0, integer: true, label: "DND5E.AbbreviationDC"}),
        scaling: new StringField$8({required: true, blank: false, initial: "spell", label: "DND5E.ScalingFormula"})
      }, {label: "DND5E.SavingThrow"}),
      summons: new SummonsField()
    };
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActionTemplate.#migrateAbility(source);
    ActionTemplate.#migrateAttack(source);
    ActionTemplate.#migrateCritical(source);
    ActionTemplate.#migrateSave(source);
  }

  /* -------------------------------------------- */

  /**
   * Migrate the ability field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateAbility(source) {
    if ( Array.isArray(source.ability) ) source.ability = source.ability[0];
  }

  /* -------------------------------------------- */

  /**
   * Move 'attackBonus' to 'attack.bonus' and ensure a 0 or null is converted to an empty string rather than "0".
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateAttack(source) {
    if ( "attackBonus" in source ) {
      source.attack ??= {};
      source.attack.bonus ??= source.attackBonus;
    }
    if ( [0, "0", null].includes(source.attack?.bonus) ) source.attack.bonus = "";
  }

  /* -------------------------------------------- */

  /**
   * Ensure the critical field is an object.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateCritical(source) {
    if ( !("critical" in source) ) return;
    if ( (typeof source.critical !== "object") || (source.critical === null) ) source.critical = {
      threshold: null,
      damage: ""
    };
    if ( source.critical.damage === null ) source.critical.damage = "";
  }

  /* -------------------------------------------- */

  /**
   * Migrate the save field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateSave(source) {
    if ( !("save" in source) ) return;
    source.save ??= {};
    if ( source.save.scaling === "" ) source.save.scaling = "spell";
    if ( source.save.ability === null ) source.save.ability = "";
    if ( typeof source.save.dc === "string" ) {
      if ( source.save.dc === "" ) source.save.dc = null;
      else if ( Number.isNumeric(source.save.dc) ) source.save.dc = Number(source.save.dc);
    }
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Which ability score modifier is used by this item?
   * @type {string|null}
   */
  get abilityMod() {
    if ( this.ability === "none" ) return null;
    return this.ability || this._typeAbilityMod || {
      mwak: "str",
      rwak: "dex",
      msak: this.parent?.actor?.system.attributes.spellcasting || "int",
      rsak: this.parent?.actor?.system.attributes.spellcasting || "int"
    }[this.actionType] || null;
  }

  /* -------------------------------------------- */

  /**
   * Default ability key defined for this type.
   * @type {string|null}
   * @internal
   */
  get _typeAbilityMod() {
    return null;
  }

  /* -------------------------------------------- */

  /**
   * What is the critical hit threshold for this item? Uses the smallest value from among the following sources:
   *  - `critical.threshold` defined on the item
   *  - `critical.threshold` defined on ammunition, if consumption mode is set to ammo
   *  - Type-specific critical threshold
   * @type {number|null}
   */
  get criticalThreshold() {
    if ( !this.hasAttack ) return null;
    let ammoThreshold = Infinity;
    if ( this.hasAmmo ) {
      ammoThreshold = this.parent?.actor?.items.get(this.consume.target)?.system.critical.threshold ?? Infinity;
    }
    const threshold = Math.min(this.critical.threshold ?? Infinity, this._typeCriticalThreshold, ammoThreshold);
    return threshold < Infinity ? threshold : 20;
  }

  /* -------------------------------------------- */

  /**
   * Default critical threshold for this type.
   * @type {number}
   * @internal
   */
  get _typeCriticalThreshold() {
    return Infinity;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement an ability check as part of its usage?
   * @type {boolean}
   */
  get hasAbilityCheck() {
    return (this.actionType === "abil") && !!this.ability;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement an attack roll as part of its usage?
   * @type {boolean}
   */
  get hasAttack() {
    return ["mwak", "rwak", "msak", "rsak"].includes(this.actionType);
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a damage roll as part of its usage?
   * @type {boolean}
   */
  get hasDamage() {
    return this.actionType && (this.damage.parts.length > 0);
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a saving throw as part of its usage?
   * @type {boolean}
   */
  get hasSave() {
    return this.actionType && !!(this.save.ability && this.save.scaling);
  }

  /* -------------------------------------------- */

  /**
   * Does this Item implement summoning as part of its usage?
   * @type {boolean}
   */
  get hasSummoning() {
    return (this.actionType === "summ") && !!this.summons?.profiles.length;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item provide an amount of healing instead of conventional damage?
   * @type {boolean}
   */
  get isHealing() {
    return (this.actionType === "heal") && this.hasDamage;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item implement a versatile damage roll as part of its usage?
   * @type {boolean}
   */
  get isVersatile() {
    return this.actionType && !!(this.hasDamage && this.damage.versatile);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getRollData(options) {
    const data = super.getRollData(options);
    const key = this.abilityMod;
    if ( data && key && ("abilities" in data) ) {
      const ability = data.abilities[key];
      data.mod = ability?.mod ?? 0;
    }
    return data;
  }
}

const { BooleanField: BooleanField$1, NumberField: NumberField$2, SchemaField: SchemaField$4, StringField: StringField$7 } = foundry.data.fields;

/**
 * @deprecated since 4.0, targeted for removal in 4.4
 * @mixin
 */
class ActivatedEffectTemplate extends SystemDataModel {
  /** @inheritDoc */
  static defineSchema() {
    foundry.utils.logCompatibilityWarning(
      "The `ActivatedEffectTemplate` data model has been deprecated in favor of `ActivitiesTemplate`.",
      { since: "DnD5e 4.0", until: "DnD5e 4.4", once: true }
    );
    return {
      activation: new SchemaField$4({
        type: new StringField$7({required: true, blank: true, label: "DND5E.ItemActivationType"}),
        cost: new NumberField$2({required: true, label: "DND5E.ItemActivationCost"}),
        condition: new StringField$7({required: true, label: "DND5E.ItemActivationCondition"})
      }, {label: "DND5E.ItemActivation"}),
      duration: new SchemaField$4({
        value: new FormulaField({required: true, deterministic: true, label: "DND5E.Duration"}),
        units: new StringField$7({required: true, blank: true, label: "DND5E.DurationType"})
      }, {label: "DND5E.Duration"}),
      target: new SchemaField$4({
        value: new FormulaField({required: true, deterministic: true, label: "DND5E.TargetValue"}),
        width: new NumberField$2({required: true, min: 0, label: "DND5E.TargetWidth"}),
        units: new StringField$7({required: true, blank: true, label: "DND5E.TargetUnits"}),
        type: new StringField$7({required: true, blank: true, label: "DND5E.TargetType"}),
        prompt: new BooleanField$1({initial: true, label: "DND5E.TemplatePrompt"})
      }, {label: "DND5E.Target"}),
      range: new SchemaField$4({
        value: new NumberField$2({required: true, min: 0, label: "DND5E.RangeNormal"}),
        long: new NumberField$2({required: true, min: 0, label: "DND5E.RangeLong"}),
        units: new StringField$7({required: true, blank: true, label: "DND5E.RangeUnits"})
      }, {label: "DND5E.Range"}),
      consume: new SchemaField$4({
        type: new StringField$7({required: true, blank: true, label: "DND5E.ConsumeType"}),
        target: new StringField$7({
          required: true, nullable: true, initial: null, label: "DND5E.ConsumeTarget"
        }),
        amount: new NumberField$2({required: true, integer: true, label: "DND5E.ConsumeAmount"}),
        scale: new BooleanField$1({label: "DND5E.ConsumeScaling"})
      }, {label: "DND5E.ConsumeTitle"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Extension of SchemaField used to track item uses.
   * @internal
   */
  static ItemUsesField = class ItemUsesField extends SchemaField$4 {
    constructor(extraSchema, options) {
      super(SystemDataModel.mergeSchema({
        value: new NumberField$2({
          required: true, min: 0, integer: true, label: "DND5E.LimitedUsesAvailable"
        }),
        max: new FormulaField({required: true, deterministic: true, label: "DND5E.LimitedUsesMax"}),
        per: new StringField$7({
          required: true, nullable: true, blank: false, initial: null, label: "DND5E.LimitedUsesPer"
        }),
        recovery: new FormulaField({required: true, label: "DND5E.RecoveryFormula"}),
        prompt: new BooleanField$1({initial: true, label: "DND5E.LimitedUsesPrompt"})
      }, extraSchema), options);
    }
  };

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /**
   * Retrieve information on available uses for display.
   * @returns {{value: number, max: number, name: string}}
   */
  getUsesData() {
    return { value: this.uses.value, max: this.parent.system.uses.max, name: "system.uses.value" };
  }

  /* -------------------------------------------- */

  /**
   * Prepare activated effect data, should be called during `prepareFinalData` stage.
   */
  prepareFinalActivatedEffectData() {
    // Initial data modifications
    if ( ["inst", "perm"].includes(this.duration.units) ) this.duration.value = null;
    if ( [null, "self"].includes(this.target.type) ) this.target.value = this.target.units = null;
    else if ( this.target.units === "touch" ) this.target.value = null;
    if ( [null, "touch", "self"].includes(this.range.units) ) this.range.value = this.range.long = null;

    // Prepare duration, targets, and max uses formulas
    const rollData = this.getRollData({ deterministic: true });
    this._prepareFinalFormula("duration.value", { label: "DND5E.Duration", rollData });
    this._prepareFinalFormula("target.value", { label: "DND5E.TargetValue", rollData });
    this._prepareFinalFormula("uses.max", { label: "DND5E.UsesMax", rollData });

    // Prepare labels
    this.parent.labels ??= {};
    this.parent.labels.duration = [this.duration.value, CONFIG.DND5E.timePeriods[this.duration.units]].filterJoin(" ");
    this.parent.labels.concentrationDuration = this.properties?.has("concentration")
      ? game.i18n.format("DND5E.ConcentrationDuration", {
          duration: this.parent.labels.duration.toLocaleLowerCase(game.i18n.lang)
      }) : this.parent.labels.duration;
    this.parent.labels.activation = this.activation.type ? [
      (this.activation.type in CONFIG.DND5E.staticAbilityActivationTypes) ? null : this.activation.cost,
      CONFIG.DND5E.abilityActivationTypes[this.activation.type]
    ].filterJoin(" ") : "";

    if ( this.hasTarget ) {
      const target = [this.target.value];
      if ( this.hasAreaTarget ) {
        if ( this.target.units in CONFIG.DND5E.movementUnits ) {
          target.push(CONFIG.DND5E.movementUnits[this.target.units].abbreviation);
        }
        else target.push(CONFIG.DND5E.distanceUnits[this.target.units]);
      }
      target.push(CONFIG.DND5E.targetTypes[this.target.type]);
      this.parent.labels.target = target.filterJoin(" ");
    }

    if ( this.isActive && this.range.units ) {
      const range = [this.range.value, this.range.long ? `/ ${this.range.long}` : null];
      if ( this.range.units in CONFIG.DND5E.movementUnits ) {
        range.push(CONFIG.DND5E.movementUnits[this.range.units].abbreviation);
      }
      else range.push(CONFIG.DND5E.distanceUnits[this.range.units]);
      this.parent.labels.range = range.filterJoin(" ");
    } else this.parent.labels.range = game.i18n.localize("DND5E.None");

    if ( this.recharge ) this.parent.labels.recharge = `${game.i18n.localize("DND5E.Recharge")} [${
      `${this.recharge.value}${parseInt(this.recharge.value) < 6 ? "+" : ""}`
    }]`;

    // Substitute source UUIDs in consumption targets
    if ( !this.parent.isEmbedded ) return;
    if ( ["ammo", "charges", "material"].includes(this.consume.type) && this.consume.target?.includes(".") ) {
      const item = this.parent.actor.sourcedItems?.get(this.consume.target, { legacy: false })?.first();
      if ( item ) this.consume.target = item.id;
    }
  }

  /* -------------------------------------------- */

  /**
   * Prepare a specific final formula, passing resolution errors to actor if available.
   * @param {string} keyPath           Path within system data to where the property can be found.
   * @param {object} options
   * @param {string} options.label     Localizable name for the property to display in warnings.
   * @param {object} options.rollData  Roll data to use to evaluate the formula.
   */
  _prepareFinalFormula(keyPath, { label, rollData }) {
    const value = foundry.utils.getProperty(this, keyPath);
    if ( !value ) return;
    const property = game.i18n.localize(label);
    try {
      const formula = replaceFormulaData(value, rollData, { item: this.parent, property });
      const roll = new Roll(formula);
      foundry.utils.setProperty(this, keyPath, roll.evaluateSync().total);
    } catch(err) {
      if ( this.parent.isEmbedded ) {
        const message = game.i18n.format("DND5E.FormulaMalformedError", { property, name: this.parent.name });
        this.parent.actor._preparationWarnings.push({ message, link: this.parent.uuid, type: "error" });
        console.error(message, err);
      }
    }
  }

  /* -------------------------------------------- */
  /*  Data Migration                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  static _migrateData(source) {
    super._migrateData(source);
    ActivatedEffectTemplate.#migrateFormulaFields(source);
    ActivatedEffectTemplate.#migrateRanges(source);
    ActivatedEffectTemplate.#migrateTargets(source);
    ActivatedEffectTemplate.#migrateUses(source);
    ActivatedEffectTemplate.#migrateConsume(source);
  }

  /* -------------------------------------------- */

  /**
   * Ensure a 0 or null in max uses & durations are converted to an empty string rather than "0". Convert numbers into
   * strings.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateFormulaFields(source) {
    if ( [0, "0", null].includes(source.uses?.max) ) source.uses.max = "";
    else if ( typeof source.uses?.max === "number" ) source.uses.max = source.uses.max.toString();
    if ( [0, "0", null].includes(source.duration?.value) ) source.duration.value = "";
    else if ( typeof source.duration?.value === "number" ) source.duration.value = source.duration.value.toString();
  }

  /* -------------------------------------------- */

  /**
   * Fix issue with some imported range data that uses the format "100/400" in the range field,
   * rather than splitting it between "range.value" & "range.long".
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateRanges(source) {
    if ( !("range" in source) ) return;
    source.range ??= {};
    if ( source.range.units === "none" ) source.range.units = "";
    if ( typeof source.range.long === "string" ) {
      if ( source.range.long === "" ) source.range.long = null;
      else if ( Number.isNumeric(source.range.long) ) source.range.long = Number(source.range.long);
    }
    if ( typeof source.range.value !== "string" ) return;
    if ( source.range.value === "" ) {
      source.range.value = null;
      return;
    }
    const [value, long] = source.range.value.split("/");
    if ( Number.isNumeric(value) ) source.range.value = Number(value);
    if ( Number.isNumeric(long) ) source.range.long = Number(long);
  }

  /* -------------------------------------------- */

  /**
   * Ensure blank strings in targets are converted to null.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateTargets(source) {
    if ( !("target" in source) ) return;
    source.target ??= {};
    if ( source.target.value === "" ) source.target.value = null;
    if ( source.target.type === "none" ) source.target.type = "";
  }

  /* -------------------------------------------- */

  /**
   * Ensure a blank string in uses.value is converted to null.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateUses(source) {
    if ( !("uses" in source) ) return;
    source.uses ??= {};
    const value = source.uses.value;
    if ( typeof value === "string" ) {
      if ( value === "" ) source.uses.value = null;
      else if ( Number.isNumeric(value) ) source.uses.value = Number(source.uses.value);
    }
  }

  /* -------------------------------------------- */

  /**
   * Migrate the consume field.
   * @param {object} source  The candidate source data from which the model will be constructed.
   */
  static #migrateConsume(source) {
    if ( !("consume" in source) ) return;
    source.consume ??= {};
    const amount = source.consume.amount;
    if ( typeof amount === "string" ) {
      if ( amount === "" ) source.consume.amount = null;
      else if ( Number.isNumeric(amount) ) source.consume.amount = Number(amount);
    }
  }

  /* -------------------------------------------- */
  /*  Getters                                     */
  /* -------------------------------------------- */

  /**
   * Chat properties for activated effects.
   * @type {string[]}
   */
  get activatedEffectCardProperties() {
    return [
      this.parent.labels.activation,
      this.parent.labels.target,
      this.parent.labels.range,
      this.parent.labels.duration
    ];
  }

  /* -------------------------------------------- */

  /**
   * Does the Item have an area of effect target?
   * @type {boolean}
   */
  get hasAreaTarget() {
    return this.isActive && (this.target.type in CONFIG.DND5E.areaTargetTypes);
  }

  /* -------------------------------------------- */

  /**
   * Does the Item target one or more distinct targets?
   * @type {boolean}
   */
  get hasIndividualTarget() {
    return this.isActive && (this.target.type in CONFIG.DND5E.individualTargetTypes);
  }

  /* -------------------------------------------- */

  /**
   * Is this Item limited in its ability to be used by charges or by recharge?
   * @type {boolean}
   */
  get hasLimitedUses() {
    return this.isActive && (this.uses.per in CONFIG.DND5E.limitedUsePeriods) && (this.uses.max > 0);
  }

  /* -------------------------------------------- */

  /**
   * Does this Item draw from a resource?
   * @type {boolean}
   */
  get hasResource() {
    const consume = this.consume;
    return this.isActive && !!consume.target && !!consume.type && (!this.hasAttack || (consume.type !== "ammo"));
  }

  /* -------------------------------------------- */

  /**
   * Does this Item draw from ammunition?
   * @type {boolean}
   */
  get hasAmmo() {
    const consume = this.consume;
    return this.isActive && !!consume.target && !!consume.type && this.hasAttack && (consume.type === "ammo");
  }

  /* -------------------------------------------- */

  /**
   * Does the Item duration accept an associated numeric value or formula?
   * @type {boolean}
   */
  get hasScalarDuration() {
    return this.duration.units in CONFIG.DND5E.scalarTimePeriods;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item range accept an associated numeric value?
   * @type {boolean}
   */
  get hasScalarRange() {
    return this.range.units in CONFIG.DND5E.movementUnits;
  }

  /* -------------------------------------------- */

  /**
   * Does the Item target accept an associated numeric value?
   * @type {boolean}
   */
  get hasScalarTarget() {
    return ![null, "", "self"].includes(this.target.type);
  }

  /* -------------------------------------------- */

  /**
   * Does the Item have a target?
   * @type {boolean}
   */
  get hasTarget() {
    return this.isActive && !["", null].includes(this.target.type);
  }

  /* -------------------------------------------- */

  /**
   * Is this Item an activatable item?
   * @type {boolean}
   */
  get isActive() {
    return !!this.activation.type;
  }
}

const config$1 = {
  background: BackgroundData,
  container: ContainerData,
  class: ClassData,
  consumable: ConsumableData,
  equipment: EquipmentData,
  facility: FacilityData,
  feat: FeatData,
  loot: LootData,
  race: RaceData,
  spell: SpellData,
  subclass: SubclassData,
  tool: ToolData,
  weapon: WeaponData
};

var _module$3 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActionTemplate: ActionTemplate,
  ActivatedEffectTemplate: ActivatedEffectTemplate,
  ActivitiesTemplate: ActivitiesTemplate,
  BackgroundData: BackgroundData,
  ClassData: ClassData,
  ConsumableData: ConsumableData,
  ContainerData: ContainerData,
  EnchantmentData: EnchantmentData,
  EnchantmentError: EnchantmentError,
  EnchantmentField: EnchantmentField,
  EquipmentData: EquipmentData,
  EquippableItemTemplate: EquippableItemTemplate,
  FacilityData: FacilityData,
  FeatData: FeatData,
  IdentifiableTemplate: IdentifiableTemplate,
  ItemDescriptionTemplate: ItemDescriptionTemplate,
  ItemTypeField: ItemTypeField,
  ItemTypeTemplate: ItemTypeTemplate,
  LootData: LootData,
  MountableTemplate: MountableTemplate,
  PhysicalItemTemplate: PhysicalItemTemplate,
  RaceData: RaceData,
  SpellData: SpellData,
  SpellcastingField: SpellcastingField,
  SubclassData: SubclassData,
  SummonsData: SummonsData,
  SummonsField: SummonsField,
  ToolData: ToolData,
  WeaponData: WeaponData,
  config: config$1,
  startingEquipment: startingEquipment
});

const { HTMLField: HTMLField$3, SchemaField: SchemaField$3, SetField: SetField$2, StringField: StringField$6 } = foundry.data.fields;

/**
 * Data definition for Class Summary journal entry pages.
 *
 * @property {string} item                             UUID of the class item included.
 * @property {object} description
 * @property {string} description.value                Introductory description for the class.
 * @property {string} description.additionalHitPoints  Additional text displayed beneath the hit points section.
 * @property {string} description.additionalTraits     Additional text displayed beneath the traits section.
 * @property {string} description.additionalEquipment  Additional text displayed beneath the equipment section.
 * @property {string} description.subclass             Introduction to the subclass section.
 * @property {string} style                            Force the page style to use modern or legacy formatting, rather
 *                                                     than what is specified by the class.
 * @property {string} subclassHeader                   Subclass header to replace the default.
 * @property {Set<string>} subclassItems               UUIDs of all subclasses to display.
 */
class ClassJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = ["JOURNALENTRYPAGE.DND5E.Class"];

  /* -------------------------------------------- */

  /** @inheritDoc */
  static defineSchema() {
    return {
      item: new StringField$6({ required: true }),
      description: new SchemaField$3({
        value: new HTMLField$3({ textSearch: true }),
        additionalHitPoints: new HTMLField$3({ textSearch: true }),
        additionalTraits: new HTMLField$3({ textSearch: true }),
        additionalEquipment: new HTMLField$3({ textSearch: true }),
        subclass: new HTMLField$3({ textSearch: true })
      }),
      style: new StringField$6(),
      subclassHeader: new StringField$6({ textSearch: true }),
      subclassItems: new SetField$2(new StringField$6())
    };
  }
}

const { StringField: StringField$5 } = foundry.data.fields;

/**
 * Data definition for Map Location journal entry pages.
 *
 * @property {string} code  Code for the location marker on the map.
 */
class MapLocationJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static defineSchema() {
    return {
      code: new StringField$5()
    };
  }

  /* -------------------------------------------- */

  /**
   * Adjust the number of this entry in the table of contents.
   * @param {number} number  Current position number.
   * @returns {{ number: string, adjustment: number }|void}
   */
  adjustTOCNumbering(number) {
    if ( !this.code ) return;
    return { number: this.code, adjustment: -1 };
  }

  /* -------------------------------------------- */

  /**
   * Create a control icon for rendering this page on a scene.
   * @param {object} options  Options passed through to ControlIcon construction.
   * @returns {PIXI.Container|void}
   */
  getControlIcon(options) {
    if ( !this.code ) return;
    const { icon: IconClass, ...style } = foundry.utils.mergeObject(
      CONFIG.DND5E.mapLocationMarker.default,
      CONFIG.DND5E.mapLocationMarker[this.parent.getFlag("dnd5e", "mapMarkerStyle")] ?? {},
      {inplace: false}
    );
    return new IconClass({code: this.code, ...options, ...style});
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    return this.parent._embedTextPage(config, options);
  }
}

const { HTMLField: HTMLField$2, StringField: StringField$4 } = foundry.data.fields;

/**
 * Data definition for Rule journal entry pages.
 *
 * @property {string} tooltip  Content to display in tooltip in place of page's text content.
 * @property {string} type     Type of rule represented. Should match an entry defined in `CONFIG.DND5E.ruleTypes`.
 */
class RuleJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static defineSchema() {
    return {
      tooltip: new HTMLField$2({textSearch: true, label: "DND5E.Rule.Tooltip"}),
      type: new StringField$4({blank: false, initial: "rule", label: "DND5E.Rule.Type.Label"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Render a rich tooltip for this page.
   * @param {EnrichmentOptions} [enrichmentOptions={}]  Options for text enrichment.
   * @returns {{content: string, classes: string[]}}
   */
  async richTooltip(enrichmentOptions={}) {
    const context = {
      page: this.parent,
      type: CONFIG.DND5E.ruleTypes[this.type].label,
      content: await TextEditor.enrichHTML(this.tooltip || this.parent.text.content, {
        secrets: false, relativeTo: this.parent, ...enrichmentOptions
      })
    };
    return {
      content: await renderTemplate("systems/dnd5e/templates/journal/page-rule-tooltip.hbs", context),
      classes: ["dnd5e-tooltip", "rule-tooltip"]
    };
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    return this.parent._embedTextPage(config, options);
  }
}

const { ArrayField, DocumentIdField, HTMLField: HTMLField$1, NumberField: NumberField$1, SchemaField: SchemaField$2, SetField: SetField$1, StringField: StringField$3 } = foundry.data.fields;

/**
 * Data needed to display spells that aren't able to be linked (outside SRD & current module).
 *
 * @typedef {object} UnlinkedSpellConfiguration
 * @property {string} _id            Unique ID for this entry.
 * @property {string} name           Name of the spell.
 * @property {object} system
 * @property {number} system.level   Spell level.
 * @property {string} system.school  Spell school.
 * @property {object} source
 * @property {string} source.book    Book/publication where the spell originated.
 * @property {string} source.page    Page or section where the spell can be found.
 * @property {string} source.custom  Fully custom source label.
 * @property {string} source.uuid    UUID of the spell, if available in another module.
 */

/**
 * Data model for spell list data.
 *
 * @property {string} type               Type of spell list (e.g. class, subclass, race, etc.).
 * @property {string} identifier         Common identifier that matches the associated type (e.g. bard, cleric).
 * @property {string} grouping           Default grouping mode.
 * @property {object} description
 * @property {string} description.value  Description to display before spell list.
 * @property {Set<string>} spells        UUIDs of spells to display.
 * @property {UnlinkedSpellConfiguration[]} unlinkedSpells  Unavailable spells that are entered manually.
 */
class SpellListJournalPageData extends foundry.abstract.TypeDataModel {
  /** @override */
  static defineSchema() {
    return {
      type: new StringField$3({
        initial: "class", label: "JOURNALENTRYPAGE.DND5E.SpellList.Type.Label"
      }),
      identifier: new IdentifierField({label: "DND5E.Identifier"}),
      grouping: new StringField$3({
        initial: "level", choices: this.GROUPING_MODES,
        label: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Label",
        hint: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Hint"
      }),
      description: new SchemaField$2({
        value: new HTMLField$1({textSearch: true, label: "DND5E.Description"})
      }),
      spells: new SetField$1(new StringField$3(), {label: "DND5E.ItemTypeSpellPl"}),
      unlinkedSpells: new ArrayField(new SchemaField$2({
        _id: new DocumentIdField({initial: () => foundry.utils.randomID()}),
        name: new StringField$3({required: true, label: "Name"}),
        system: new SchemaField$2({
          level: new NumberField$1({min: 0, integer: true, label: "DND5E.Level"}),
          school: new StringField$3({label: "DND5E.School"})
        }),
        source: new SourceField({license: false, revision: false, rules: false, uuid: new StringField$3()})
      }), {label: "JOURNALENTRYPAGE.DND5E.SpellList.UnlinkedSpells.Label"})
    };
  }

  /* -------------------------------------------- */

  /**
   * Different ways in which spells can be grouped on the sheet.
   * @enum {string}
   */
  static GROUPING_MODES = {
    none: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.None",
    alphabetical: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Alphabetical",
    level: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.Level",
    school: "JOURNALENTRYPAGE.DND5E.SpellList.Grouping.School"
  };

  /* -------------------------------------------- */

  /** @inheritDoc */
  prepareDerivedData() {
    this.unlinkedSpells.forEach(s => SourceField.prepareData.call(s.source, s.source?.uuid));
  }

  /* -------------------------------------------- */

  /** @override */
  async toEmbed(config, options={}) {
    for ( const value of config.values ) {
      if ( value === "table" ) config.table = true;
      else if ( value in this.constructor.GROUPING_MODES ) config.grouping = value;
    }
    if ( config.table ) config.grouping = "level";

    const sheet = new JournalSpellListPageSheet(this.parent, {
      editable: false, displayAsTable: config.table, embedRendering: true, grouping: config.grouping
    });
    const rendered = await sheet._renderInner(await sheet.getData());
    config.classes = config.classes ? `spells ${config.classes ?? ""}` : "spells";
    return rendered[0];
  }
}

const { HTMLField, SchemaField: SchemaField$1, StringField: StringField$2 } = foundry.data.fields;

/**
 * Data definition for Subclass Summary journal entry pages.
 *
 * @property {string} item               UUID of the subclass item included.
 * @property {object} description
 * @property {string} description.value  Introductory description for the subclass.
 * @property {string} style              Force the page style to use modern or legacy formatting, rather than what
 *                                       is specified by the subclass.
 */
class SubclassJournalPageData extends foundry.abstract.TypeDataModel {

  /** @inheritDoc */
  static LOCALIZATION_PREFIXES = ["JOURNALENTRYPAGE.DND5E.Subclass"];

  /* -------------------------------------------- */
  /** @inheritDoc */
  static defineSchema() {
    return {
      item: new StringField$2({ required: true }),
      description: new SchemaField$1({
        value: new HTMLField({ textSearch: true })
      }),
      style: new StringField$2()
    };
  }
}

const config = {
  class: ClassJournalPageData,
  map: MapLocationJournalPageData,
  rule: RuleJournalPageData,
  spells: SpellListJournalPageData,
  subclass: SubclassJournalPageData
};

var _module$2 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ClassJournalPageData: ClassJournalPageData,
  MapLocationJournalPageData: MapLocationJournalPageData,
  RuleJournalPageData: RuleJournalPageData,
  SpellListJournalPageData: SpellListJournalPageData,
  SubclassJournalPageData: SubclassJournalPageData,
  config: config
});

const { BooleanField, ForeignDocumentField, NumberField, SchemaField, SetField, StringField: StringField$1 } = foundry.data.fields;

/**
 * @typedef {object} SheetPreferences5e
 * @property {number|null} width                      The preferred width of the sheet.
 * @property {number|null} height                     The preferred height of the sheet.
 * @property {Record<string, TabPreferences5e>} tabs  The User's tab preferences.
 */

/**
 * @typedef {object} TabPreferences5e
 * @property {boolean} [collapseSidebar]  Whether this tab should have the sidebar collapsed.
 * @property {boolean} [group]            Whether to group items by type.
 * @property {string} [sort]              The item sort mode.
 */

/**
 * A custom model to validate system flags on User Documents.
 *
 * @property {Set<string>} awardDestinations                  Saved targets from previous use of /award command.
 * @property {object} creation
 * @property {string} creation.scrollExplanation              Default explanation mode for spell scrolls.
 * @property {Record<string, SheetPreferences5e>} sheetPrefs  The User's sheet preferences.
 */
class UserSystemFlags extends foundry.abstract.DataModel {
  /** @override */
  static defineSchema() {
    return {
      awardDestinations: new SetField(
        new ForeignDocumentField(foundry.documents.BaseActor, { idOnly: true }), { required: false }
      ),
      creation: new SchemaField({
        scrollExplanation: new StringField$1({initial: "reference"})
      }),
      sheetPrefs: new MappingField(new SchemaField({
        width: new NumberField({ integer: true, positive: true }),
        height: new NumberField({ integer: true, positive: true }),
        tabs: new MappingField(new SchemaField({
          collapseSidebar: new BooleanField({ required: false }),
          group: new BooleanField({ required: false, initial: true }),
          sort: new StringField$1({ required: false, initial: "m", choices: [...foundry.documents.BaseFolder.SORTING_MODES, "p"] })
        }))
      }))
    };
  }
}

var _module$1 = /*#__PURE__*/Object.freeze({
  __proto__: null,
  UserSystemFlags: UserSystemFlags
});

var dataModels = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActorDataModel: ActorDataModel,
  ItemDataModel: ItemDataModel,
  SparseDataModel: SparseDataModel,
  SystemDataModel: SystemDataModel,
  abstract: _module$c,
  activeEffect: _module$b,
  activity: _module$a,
  actor: _module$9,
  advancement: _module$8,
  chatMessage: _module$6,
  collection: _module$5,
  fields: _module$r,
  item: _module$3,
  journal: _module$2,
  shared: _module$4,
  user: _module$1
});

const { Die } = foundry.dice.terms;

/**
 * Primary die used when performing a D20 roll.
 */
class D20Die extends Die {
  constructor({ number=1, faces=20, ...args }={}) {
    super({ number, faces, ...args });
  }

  /* -------------------------------------------- */

  /**
   * Critical success target if no critical failure is set in options.
   * @type {number}
   */
  static CRITICAL_SUCCESS_TOTAL = 20;

  /* -------------------------------------------- */

  /**
   * Critical failure target if no critical failure is set in options.
   * @type {number}
   */
  static CRITICAL_FAILURE_TOTAL = 1;

  /* -------------------------------------------- */

  /**
   * Is the result of this roll a critical success? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isCriticalSuccess() {
    if ( !this.isValid || !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.criticalSuccess) ) return false;
    return this.total >= this.options.criticalSuccess;
  }

  /* -------------------------------------------- */

  /**
   * Is the result of this roll a critical failure? Returns `undefined` if roll isn't evaluated.
   * @type {boolean|void}
   */
  get isCriticalFailure() {
    if ( !this.isValid || !this._evaluated ) return;
    if ( !Number.isNumeric(this.options.criticalFailure) ) return false;
    return this.total <= this.options.criticalFailure;
  }

  /* -------------------------------------------- */

  /**
   * Is this a valid challenge die?
   * @type {boolean}
   */
  get isValid() {
    return this.faces === 20;
  }

  /* -------------------------------------------- */
  /*  Die Modification                            */
  /* -------------------------------------------- */

  /**
   * Apply advantage mode to this die.
   * @param {number} advantageMode  Advantage mode to apply.
   */
  applyAdvantage(advantageMode) {
    this.options.advantageMode = advantageMode;
    this.modifiers.findSplice(m => ["kh", "kl"].includes(m));
    if ( advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.NORMAL ) this.number = 1;
    else {
      const isAdvantage = advantageMode === CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE;
      this.number = (isAdvantage && this.options.elvenAccuracy) ? 3 : 2;
      this.modifiers.push(isAdvantage ? "kh" : "kl");
    }
  }

  /* -------------------------------------------- */

  /**
   * Set or unset the specified flag on this die.
   * @param {string} flag      Flag to apply.
   * @param {boolean} enabled  Is the flag enabled?
   */
  applyFlag(flag, enabled) {
    this.options[flag] = enabled;

    // Halfling Lucky, re-roll a natural 1 once
    if ( flag === "halflingLucky" ) {
      const index = this.modifiers.findIndex(m => m === "r1=1");
      if ( enabled && (index === -1) ) this.modifiers.push("r1=1");
      else if ( !enabled && (index !== -1) ) this.modifiers.splice(index, 1);
    }
  }

  /* -------------------------------------------- */

  /**
   * Apply a minimum or maximum value to this die.
   * @param {object} values
   * @param {number} [values.minimum]
   * @param {number} [values.maximum]
   */
  applyRange(values) {
    for ( const [key, value] of Object.entries(values) ) {
      this.options[key] = value;
      const mod = key.substring(0, 3);
      this.modifiers.findSplice(m => m.startsWith(mod));
      if ( value ) this.modifiers.push(`${mod}${value}`);
    }
  }
}

const { OperatorTerm } = foundry.dice.terms;

/* -------------------------------------------- */
/* D20 Roll                                     */
/* -------------------------------------------- */

/**
 * Configuration data for a D20 roll.
 *
 * @typedef {object} DeprecatedD20RollConfiguration
 *
 * @property {string[]} [parts=[]]  The dice roll component parts, excluding the initial d20.
 * @property {object} [data={}]     Data that will be used when parsing this roll.
 * @property {Event} [event]        The triggering event for this roll.
 *
 * ## D20 Properties
 * @property {boolean} [advantage]     Apply advantage to this roll (unless overridden by modifier keys or dialog)?
 * @property {boolean} [disadvantage]  Apply disadvantage to this roll (unless overridden by modifier keys or dialog)?
 * @property {number|null} [critical=20]  The value of the d20 result which represents a critical success,
 *                                     `null` will prevent critical successes.
 * @property {number|null} [fumble=1]  The value of the d20 result which represents a critical failure,
 *                                     `null` will prevent critical failures.
 * @property {number} [targetValue]    The value of the d20 result which should represent a successful roll.
 * @property {string|false} [ammunition]  Ammunition to use with an attack roll.
 * @property {string} [attackMode]     Default attack mode to use with an attack roll.
 * @property {string} [mastery]        Weapon mastery to use with an attack roll.
 *
 * ## Flags
 * @property {boolean} [elvenAccuracy]   Allow Elven Accuracy to modify this roll?
 * @property {boolean} [halflingLucky]   Allow Halfling Luck to modify this roll?
 * @property {boolean} [reliableTalent]  Allow Reliable Talent to modify this roll?
 *
 * ## Roll Configuration Dialog
 * @property {boolean} [fastForward]             Should the roll configuration dialog be skipped?
 * @property {FormSelectOption[]} [ammunitionOptions]  Options for ammunition to use with an attack.
 * @property {FormSelectOption[]} [attackModes]  Modes that can be used when making an attack.
 * @property {boolean} [chooseModifier=false]    If the configuration dialog is shown, should the ability modifier be
 *                                               configurable within that interface?
 * @property {FormSelectOption[]} [masteryOptions]  Weapon masteries that can be selected when making an attack.
 * @property {string} [template]                 The HTML template used to display the roll configuration dialog.
 * @property {string} [title]                    Title of the roll configuration dialog.
 * @property {object} [dialogOptions]            Additional options passed to the roll configuration dialog.
 *
 * ## Chat Message
 * @property {boolean} [chatMessage=true]  Should a chat message be created for this roll?
 * @property {object} [messageData={}]     Additional data which is applied to the created chat message.
 * @property {string} [rollMode]           Value of `CONST.DICE_ROLL_MODES` to apply as default for the chat message.
 * @property {object} [flavor]             Flavor text to use in the created chat message.
 */

/**
 * A standardized helper function for managing core 5e d20 rolls.
 * Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward".
 * This chooses the default options of a normal attack with no bonus, Advantage, or Disadvantage respectively
 *
 * @param {DeprecatedD20RollConfiguration} configuration  Configuration data for the D20 roll.
 * @returns {Promise<D20Roll|null>}             The evaluated D20Roll, or null if the workflow was cancelled.
 */
async function d20Roll({
  parts=[], data={}, event,
  advantage, disadvantage, critical=20, fumble=1, targetValue, attackMode, ammunition, mastery,
  elvenAccuracy, halflingLucky, reliableTalent,
  fastForward, ammunitionOptions, attackModes, chooseModifier=false, masteryOptions, template, title, dialogOptions,
  chatMessage=true, messageData={}, rollMode, flavor
}={}) {
  foundry.utils.logCompatibilityWarning(
    "The `d20Roll` standalone method has been deprecated and replaced with `CONFIG.Dice.D20Roll.build`.",
    { since: "DnD5e 4.1", until: "DnD5e 4.5" }
  );

  const rollConfig = {
    event, ammunition, attackMode, mastery, elvenAccuracy, halflingLucky, reliableTalent,
    rolls: [{
      parts, data,
      options: {
        advantage, disadvantage,
        criticalSuccess: critical,
        criticalFailure: fumble,
        target: targetValue
      }
    }]
  };

  const dialogConfig = {
    options: {
      ammunitionOptions,
      attackModes,
      chooseModifier,
      masteryOptions,
      ...(dialogOptions ?? {}),
      title
    }
  };
  if ( fastForward !== undefined ) dialogConfig.configure = !fastForward;

  const messageConfig = {
    create: chatMessage,
    data: {
      ...messageData,
      flavor
    },
    rollMode: rollMode ?? game.settings.get("core", "rollMode")
  };

  const rolls = await CONFIG.Dice.D20Roll.build(rollConfig, dialogConfig, messageConfig);

  return rolls?.[0] ?? null;
}

/* -------------------------------------------- */
/* Damage Roll                                  */
/* -------------------------------------------- */

/**
 * Configuration data for a damage roll.
 *
 * @typedef {object} DamageRollConfiguration
 *
 * @property {SingleDamageRollConfiguration[]} [rollConfigs=[]]  Separate roll configurations for different damages.
 * @property {string[]} [parts=[]]  The dice roll component parts.
 * @property {object} [data={}]     Data that will be used when parsing this roll.
 * @property {Event} [event]        The triggering event for this roll.
 * @property {boolean} [returnMultiple=false] Should multiple rolls be returned, or only the first?
 *
 * ## Critical Handling
 * @property {boolean} [allowCritical=true]  Is this damage roll allowed to be rolled as critical?
 * @property {boolean} [critical]            Apply critical to this roll (unless overridden by modifier key or dialog)?
 * @property {number} [criticalBonusDice]    A number of bonus damage dice that are added for critical hits.
 * @property {number} [criticalMultiplier]   Multiplier to use when calculating critical damage.
 * @property {boolean} [multiplyNumeric]     Should numeric terms be multiplied when this roll criticals?
 * @property {boolean} [powerfulCritical]    Should the critical dice be maximized rather than rolled?
 * @property {string} [criticalBonusDamage]  An extra damage term that is applied only on a critical hit.
 *
 * ## Roll Configuration Dialog
 * @property {boolean} [fastForward]        Should the roll configuration dialog be skipped?
 * @property {string} [template]            The HTML template used to render the roll configuration dialog.
 * @property {string} [title]               Title of the roll configuration dialog.
 * @property {object} [dialogOptions]       Additional options passed to the roll configuration dialog.
 *
 * ## Chat Message
 * @property {boolean} [chatMessage=true]  Should a chat message be created for this roll?
 * @property {object} [messageData={}]     Additional data which is applied to the created chat message.
 * @property {string} [rollMode]           Value of `CONST.DICE_ROLL_MODES` to apply as default for the chat message.
 * @property {string} [flavor]             Flavor text to use in the created chat message.
 */

/**
 * Configuration data for a single damage roll.
 *
 * @typedef {object} SingleDamageRollConfiguration
 * @property {string[]} parts         The dice roll component parts.
 * @property {string} [type]          Damage type represented by the roll.
 * @property {string[]} [types]       List of damage types selectable in the configuration app. If no
 *                                    type is provided, then the first of these types will be used.
 * @property {string[]} [properties]  Physical properties of the damage source (e.g. magical, silvered).
 */

/**
 * A standardized helper function for managing core 5e damage rolls.
 * Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward".
 * This chooses the default options of a normal attack with no bonus, Critical, or no bonus respectively
 *
 * @param {DamageRollConfiguration} configuration    Configuration data for the Damage roll.
 * @returns {Promise<DamageRoll|DamageRoll[]|null>}  The evaluated DamageRoll, or null if the workflow was canceled.
 */
async function damageRoll({
  rollConfigs=[], parts=[], data={}, event, returnMultiple=false,
  allowCritical=true, critical, criticalBonusDice, criticalMultiplier,
  multiplyNumeric, powerfulCritical, criticalBonusDamage,
  fastForward, template, title, dialogOptions,
  chatMessage=true, messageData={}, rollMode, flavor
}={}) {
  foundry.utils.logCompatibilityWarning(
    "The `damageRoll` standalone method has been deprecated and replaced with `CONFIG.Dice.DamageRoll.build`.",
    { since: "DnD5e 4.0", until: "DnD5e 4.4" }
  );

  const rollConfig = {
    event,
    critical: {
      allow: allowCritical,
      multiplier: criticalMultiplier,
      multiplyNumeric: multiplyNumeric ?? game.settings.get("dnd5e", "criticalDamageModifiers"),
      powerfulCritical: powerfulCritical ?? game.settings.get("dnd5e", "criticalDamageMaxDice")
    },
    rolls: rollConfigs.map(r => ({
      data,
      parts: r.parts,
      options: {
        isCritical: critical,
        properties: r.properties,
        type: r.type,
        types: r.types
      }
    }))
  };
  if ( parts.length ) rollConfig.rolls.unshift({ data, parts });
  if ( rollConfig.rolls[0] ) {
    foundry.utils.setProperty(rollConfig.rolls[0], "options.critical.bonusDice", criticalBonusDice);
    foundry.utils.setProperty(rollConfig.rolls[0], "options.critical.bonusDamage", criticalBonusDamage);
  }

  const dialogConfig = {
    options: {
      ...(dialogOptions ?? {}),
      title
    }
  };
  if ( fastForward !== undefined ) dialogConfig.configure = !fastForward;

  const messageConfig = {
    create: chatMessage,
    data: {
      ...messageData,
      flavor
    },
    rollMode: rollMode ?? game.settings.get("core", "rollMode")
  };

  const rolls = await CONFIG.Dice.DamageRoll.build(rollConfig, dialogConfig, messageConfig);

  if ( returnMultiple ) return rolls;
  if ( rolls?.length <= 1 ) return rolls[0];

  const mergedRoll = new CONFIG.Dice.DamageRoll();
  mergedRoll._total = 0;
  for ( const roll of rolls ) {
    if ( mergedRoll.terms.length ) {
      const operator = new OperatorTerm({operator: "+"});
      operator._evaluated = true;
      mergedRoll.terms.push(operator);
    }
    mergedRoll.terms.push(...roll.terms);
    mergedRoll._total += roll.total;
    mergedRoll.options = foundry.utils.mergeObject(roll.options, mergedRoll.options, { inplace: false });
  }
  mergedRoll._evaluated = true;
  mergedRoll.resetFormula();
  return mergedRoll;
}

var dice = /*#__PURE__*/Object.freeze({
  __proto__: null,
  BasicRoll: BasicRoll,
  D20Die: D20Die,
  D20Roll: D20Roll,
  DamageRoll: DamageRoll,
  aggregateDamageRolls: aggregateDamageRolls,
  d20Roll: d20Roll,
  damageRoll: damageRoll,
  simplifyRollFormula: simplifyRollFormula
});

/**
 * Extended version of Combat to trigger events on combat start & turn changes.
 */
class Combat5e extends Combat {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Expansion state for groups within this combat.
   * @type {Set<string>}
   */
  expandedGroups = new Set();

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async startCombat() {
    await super.startCombat();
    this._recoverUses({ encounter: true });
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async endCombat() {
    await super.endCombat();
    this._recoverUses({ turn: true, turnEnd: true, turnStart: true });
    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async rollInitiative(ids, options={}) {
    await super.rollInitiative(ids, options);
    for ( const id of ids ) await this._recoverUses({ initiative: this.combatants.get(id) });
    return this;
  }

  /* -------------------------------------------- */

  /** @override */
  _sortCombatants(a, b) {
    // Initiative takes top priority
    if ( a.initiative !== b.initiative ) return super._sortCombatants(a, b);

    // Separate out combatants with different base actors
    if ( !a.token?.baseActor || !b.token?.baseActor || (a.token?.baseActor !== b.token?.baseActor) ) {
      const name = c => `${c.token?.baseActor?.name ?? ""}.${c.token?.baseActor?.id ?? ""}`;
      return name(a).localeCompare(name(b), game.i18n.lang);
    }

    // Otherwise sort based on combatant name
    return a.name.localeCompare(b.name, game.i18n.lang);
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( this.current.combatantId !== this.previous.combatantId ) {
      this.combatants.get(this.previous.combatantId)?.refreshDynamicRing();
      this.combatants.get(this.current.combatantId)?.refreshDynamicRing();
    }
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    this.combatants.get(this.current.combatantId)?.refreshDynamicRing();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onEndTurn(combatant) {
    await super._onEndTurn(combatant);
    this._recoverUses({ turnEnd: combatant });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _onStartTurn(combatant) {
    await super._onStartTurn(combatant);
    this._recoverUses({ turn: true, turnStart: combatant });
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Determine which group each combatant should be added to, or if a new group should be created.
   * @returns {Map<string, { combatants: Combatant5e[], expanded: boolean }>}
   */
  createGroups() {
    const groups = new Map();
    for ( const combatant of this.combatants ) {
      const key = combatant.getGroupingKey();
      if ( key === null ) continue;
      if ( !groups.has(key) ) groups.set(key, { combatants: [], expanded: this.expandedGroups.has(key) });
      groups.get(key).combatants.push(combatant);
    }

    for ( const [key, { combatants }] of groups.entries() ) {
      if ( combatants.length <= 1 ) groups.delete(key);
    }

    return groups;
  }

  /* -------------------------------------------- */

  /**
   * Reset combat specific uses.
   * @param {object} types  Which types of recovery to handle, and whether they should be performed on all combatants
   *                        or only the combatant specified.
   * @protected
   */
  async _recoverUses(types) {
    for ( const combatant of this.combatants ) {
      const periods = Object.entries(types).filter(([, v]) => (v === true) || (v === combatant)).map(([k]) => k);
      if ( periods.length ) await combatant.recoverCombatUses(periods);
    }
  }
}

/**
 * @import { ActorDeltasData } from "../data/chat-message/fields/deltas-field.mjs";
 */

/**
 * @typedef CombatRecoveryResults
 * @property {object} actor       Updates to be applied to the actor.
 * @property {object[]} item      Updates to be applied to the actor's items.
 * @property {BasicRoll[]} rolls  Any recovery rolls performed.
 */

/**
 * Custom combatant with custom initiative roll handling.
 */
class Combatant5e extends Combatant {
  /**
   * Create a chat message representing actor changes and displaying possible actions for this turn.
   * @param {object} [data={}]
   * @param {ActorDeltasData} [data.deltas]
   * @param {string[]} [data.periods]
   * @param {BasicRoll[]} [data.rolls]
   * @returns {ChatMessage5e|void}
   */
  async createTurnMessage({ deltas, periods, rolls }={}) {
    const messageConfig = {
      create: false,
      data: {
        rolls,
        speaker: ChatMessage.getSpeaker({ actor: this.actor, token: this.token }),
        system: {
          deltas, periods,
          activations: ActivationsField.getActivations(this.actor, periods),
          origin: {
            combat: this.combat.id,
            combatant: this.id
          }
        },
        type: "turn",
        whisper: game.users.filter(u => this.actor.testUserPermission(u, "OWNER"))
      }
    };

    if ( !foundry.utils.isEmpty(messageConfig.data.system.deltas?.actor)
      || !foundry.utils.isEmpty(messageConfig.data.system.deltas?.item)
      || !foundry.utils.isEmpty(messageConfig.data.system.activations) ) messageConfig.create = true;

    /**
     * A hook event that fires before a combat state change chat message is created.
     * @function dnd5e.preCreateCombatMessage
     * @memberof hookEvents
     * @param {Combatant5e} combatant         Combatant for which the message will be created.
     * @param {object} messageConfig
     * @param {boolean} messageConfig.create  Should the chat message be posted?
     * @param {object} messageConfig.data     Data for the created chat message.
     */
    Hooks.callAll("dnd5e.preCreateCombatMessage", this, messageConfig);

    if ( messageConfig.create ) return ChatMessage.implementation.create(messageConfig.data);
  }

  /* -------------------------------------------- */

  /**
   * Key for the group to which this combatant should belong, or `null` if it can't be grouped.
   * @returns {boolean|null}
   */
  getGroupingKey() {
    if ( this.token?.actorLink || !this.token?.baseActor || (this.initiative === null) ) return null;
    return `${Math.floor(this.initiative).paddedString(4)}:${this.token.disposition}:${this.token.baseActor.id}`;
  }

  /* -------------------------------------------- */

  /** @override */
  getInitiativeRoll(formula) {
    if ( !this.actor ) return new CONFIG.Dice.D20Roll(formula ?? "1d20", {});
    return this.actor.getInitiativeRoll();
  }

  /* -------------------------------------------- */

  /**
   * Reset combat-related uses.
   * @param {string[]} periods  Which recovery periods should be considered.
   */
  async recoverCombatUses(periods) {
    /**
     * A hook event that fires before combat-related recovery changes.
     * @function dnd5e.preCombatRecovery
     * @memberof hookEvents
     * @param {Combatant5e} combatant  Combatant that is being recovered.
     * @param {string[]} periods       Periods to be recovered.
     * @returns {boolean}              Explicitly return `false` to prevent recovery from being performed.
     */
    if ( Hooks.call("dnd5e.preCombatRecovery", this, periods) === false ) return;

    const results = { actor: {}, item: [], rolls: [] };
    await this.actor?.system.recoverCombatUses?.(periods, results);

    for ( const item of this.actor?.items ?? [] ) {
      if ( foundry.utils.getType(item.system.recoverUses) !== "function" ) continue;
      const rollData = item.getRollData();
      const { updates, rolls } = await item.system.recoverUses(Array.from(periods), rollData);
      if ( !foundry.utils.isEmpty(updates) ) {
        const updateTarget = results.item.find(i => i._id === item.id);
        if ( updateTarget ) foundry.utils.mergeObject(updateTarget, updates);
        else results.item.push({ _id: item.id, ...updates });
      }
      results.rolls.push(...rolls);
    }

    /**
     * A hook event that fires after combat-related recovery changes have been prepared, but before they have been
     * applied to the actor.
     * @function dnd5e.combatRecovery
     * @memberof hookEvents
     * @param {Combatant5e} combatant          Combatant that is being recovered.
     * @param {string[]} periods               Periods that were recovered.
     * @param {CombatRecoveryResults} results  Update that will be applied to the actor and its items.
     * @returns {boolean}  Explicitly return `false` to prevent updates from being performed.
     */
    if ( Hooks.call("dnd5e.combatRecovery", this, periods, results) === false ) return;

    const deltas = ActorDeltasField.getDeltas(this.actor, results);

    if ( !foundry.utils.isEmpty(results.actor) ) await this.actor.update(results.actor);
    if ( results.item.length ) await this.actor.updateEmbeddedDocuments("Item", results.item);

    const message = await this.createTurnMessage({ deltas, periods, rolls: results.rolls });

    /**
     * A hook event that fires after combat-related recovery changes have been applied.
     * @function dnd5e.postCombatRecovery
     * @memberof hookEvents
     * @param {Combatant5e} combatant       Combatant that is being recovered.
     * @param {string[]} periods            Periods that were recovered.
     * @param {ChatMessage5e|void} message  Chat message created, if any.
     */
    Hooks.callAll("dnd5e.postCombatRecovery", this, periods, message);
  }

  /* -------------------------------------------- */

  /**
   * Trigger this combatant's dynamic token to refresh.
   */
  refreshDynamicRing() {
    if ( !this.token?.hasDynamicRing ) return;
    this.token.object?.renderFlags.set({ refreshRingVisuals: true });
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    requestAnimationFrame(() => this.refreshDynamicRing());
  }
}

/**
 * Custom implementation of journal entry pages for providing roll data.
 */
class JournalEntryPage5e extends JournalEntryPage {
  /**
   * Return a data object regarding this page and from the containing journal entry.
   * @returns {object}
   */
  getRollData() {
    const { name, flags, system } = this;
    return {
      name: this.parent.name,
      flags: this.parent.flags,
      page: { ...system, name, flags }
    };
  }
}

/**
 * Extend the base TokenDocument class to implement system-specific HP bar logic.
 */
class TokenDocument5e extends SystemFlagsMixin(TokenDocument) {

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Is the dynamic token ring enabled?
   * @type {boolean}
   */
  get hasDynamicRing() {
    return this.ring.enabled;
  }

  /* -------------------------------------------- */
  /*  Migrations                                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeSource(data, options={}) {
    if ( data instanceof foundry.abstract.DataModel ) data = data.toObject();

    // Migrate backpack -> container.
    for ( const item of data.delta?.items ?? [] ) {
      // This will be correctly flagged as needing a source migration when the synthetic actor is created, but we need
      // to also change the type in the raw ActorDelta to avoid spurious console warnings.
      if ( item.type === "backpack" ) item.type = "container";
    }
    return super._initializeSource(data, options);
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  getBarAttribute(barName, options={}) {
    const attribute = options.alternative || this[barName]?.attribute;
    if ( attribute?.startsWith(".") ) {
      const item = fromUuidSync(attribute, { relative: this.actor });
      const { value, max } = item?.system.uses ?? { value: 0, max: 0 };
      if ( max ) return { attribute, value, max, type: "bar", editable: true };
    }

    const data = super.getBarAttribute(barName, options);
    if ( data?.attribute === "attributes.hp" ) {
      const hp = this.actor.system.attributes.hp || {};
      data.value += (hp.temp || 0);
      data.max = Math.max(0, hp.effectiveMax);
    }
    return data;
  }

  /* -------------------------------------------- */

  /**
   * Get an Array of attribute choices which are suitable for being consumed by an item usage.
   * @param {object} data  The actor data.
   * @returns {string[]}
   */
  static getConsumedAttributes(data) {
    return CONFIG.DND5E.consumableResources;
  }

  /* -------------------------------------------- */

  static getTrackedAttributeChoices(attributes) {
    if ( game.release.generation < 13 ) return this.getTrackedAttributeChoicesV12(attributes);
    const groups = super.getTrackedAttributeChoices(attributes);
    const i18n = {
      abilities: game.i18n.localize("DND5E.AbilityScorePl"),
      movement: game.i18n.localize("DND5E.MovementSpeeds"),
      senses: game.i18n.localize("DND5E.Senses"),
      skills: game.i18n.localize("DND5E.SkillPassives"),
      slots: game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlots")
    };
    for ( const entry of groups ) {
      const { value } = entry;
      if ( value.startsWith("abilities.") ) entry.group = i18n.abilities;
      else if ( value.startsWith("attributes.movement.") ) entry.group = i18n.movement;
      else if ( value.startsWith("attributes.senses.") ) entry.group = i18n.senses;
      else if ( value.startsWith("skills.") ) entry.group = i18n.skills;
      else if ( value.startsWith("spells.") ) entry.group = i18n.slots;
    }
    return groups;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  static getTrackedAttributeChoicesV12(attributes) {
    // TODO: Remove when v12 support is dropped.
    const groups = super.getTrackedAttributeChoices(attributes);
    const abilities = [];
    const movement = [];
    const senses = [];
    const skills = [];
    const slots = [];

    // Regroup existing attributes based on their path.
    for ( const group of Object.values(groups) ) {
      for ( let i = 0; i < group.length; i++ ) {
        const attribute = group[i];
        if ( attribute.startsWith("abilities.") ) abilities.push(attribute);
        else if ( attribute.startsWith("attributes.movement.") ) movement.push(attribute);
        else if ( attribute.startsWith("attributes.senses.") ) senses.push(attribute);
        else if ( attribute.startsWith("skills.") ) skills.push(attribute);
        else if ( attribute.startsWith("spells.") ) slots.push(attribute);
        else continue;
        group.splice(i--, 1);
      }
    }

    // Add new groups to choices.
    if ( abilities.length ) groups[game.i18n.localize("DND5E.AbilityScorePl")] = abilities;
    if ( movement.length ) groups[game.i18n.localize("DND5E.MovementSpeeds")] = movement;
    if ( senses.length ) groups[game.i18n.localize("DND5E.Senses")] = senses;
    if ( skills.length ) groups[game.i18n.localize("DND5E.SkillPassives")] = skills;
    if ( slots.length ) groups[game.i18n.localize("JOURNALENTRYPAGE.DND5E.Class.SpellSlots")] = slots;
    return groups;
  }

  /* -------------------------------------------- */

  /** @override */
  prepareData() {
    super.prepareData();
    if ( !this.hasDynamicRing ) return;
    let size = this.baseActor?.system.traits?.size;
    if ( !this.actorLink ) {
      const deltaSize = this.delta.system.traits?.size;
      if ( deltaSize ) size = deltaSize;
    }
    if ( !size ) return;
    const dts = CONFIG.DND5E.actorSizes[size].dynamicTokenScale ?? 1;
    this.texture.scaleX = this._source.texture.scaleX * dts;
    this.texture.scaleY = this._source.texture.scaleY * dts;
  }

  /* -------------------------------------------- */
  /*  Ring Animations                             */
  /* -------------------------------------------- */

  /**
   * Determine if any rings colors should be forced based on current status.
   * @returns {{[ring]: number, [background]: number}}
   */
  getRingColors() {
    const colors = {};
    if ( this.hasStatusEffect(CONFIG.specialStatusEffects.DEFEATED) ) {
      colors.ring = CONFIG.DND5E.tokenRingColors.defeated;
    }
    return colors;
  }

  /* -------------------------------------------- */

  /**
   * Determine what ring effects should be applied on top of any set by flags.
   * @returns {string[]}
   */
  getRingEffects() {
    const e = foundry.canvas.tokens.TokenRing.effects;
    const effects = [];
    if ( this.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE) ) effects.push(e.INVISIBILITY);
    else if ( this === game.combat?.combatant?.token ) effects.push(e.RING_GRADIENT);
    return effects;
  }

  /* -------------------------------------------- */

  /**
   * Flash the token ring based on damage, healing, or temp HP.
   * @param {string} type     The key to determine the type of flashing.
   */
  flashRing(type) {
    if ( !this.rendered ) return;
    const color = CONFIG.DND5E.tokenRingColors[type];
    if ( !color ) return;
    const options = {};
    if ( type === "damage" ) {
      options.duration = 500;
      options.easing = foundry.canvas.tokens.TokenRing.easeTwoPeaks;
    }
    this.object.ring?.flashColor(Color.from(color), options);
  }

  /* -------------------------------------------- */
  /*  Event Handlers                              */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    if ( (await super._preCreate(data, options, user)) === false ) return false;

    if ( (this.actor?.type === "npc") && !this.actorLink
      && foundry.utils.getProperty(this.actor, "system.attributes.hp.formula")?.trim().length ) {
      const autoRoll = game.settings.get("dnd5e", "autoRollNPCHP");
      if ( autoRoll === "no" ) return;
      const roll = await this.actor.rollNPCHitPoints({ chatMessage: autoRoll === "yes" });
      this.delta.updateSource({
        "system.attributes.hp": {
          max: roll.total,
          value: roll.total
        }
      });
    }
  }

  /* -------------------------------------------- */
  /*  Socket Event Handlers                       */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);

    const origin = this.actor?.getFlag("dnd5e", "summon.origin");
    // TODO: Replace with parseUuid once V11 support is dropped
    if ( origin ) dnd5e.registry.summons.untrack(origin.split(".Item.")[0], this.actor.uuid);
  }
}

/**
 * Extend the basic User implementation.
 * @extends {User}
 */
class User5e extends SystemFlagsMixin(User) {
  /** @inheritDoc */
  get _systemFlagsDataModel() {
    return UserSystemFlags;
  }
}

/**
 * @typedef BastionTurnResult
 * @property {string} [order]             The order that was completed, if any.
 * @property {number} [gold]              Gold generated.
 * @property {BastionTurnItem[]} [items]  Items created.
 */

/**
 * @typedef BastionTurnItem
 * @property {string} uuid      The UUID of the generated Item.
 * @property {number} quantity  The quantity of items generated.
 */

/**
 * A singleton class that manages global Bastion activity.
 */
class Bastion {
  /**
   * The template for the chat card summary of a bastion attack.
   * @type {string}
   */
  static ATTACK_TEMPLATE = "systems/dnd5e/templates/chat/bastion-attack-summary.hbs";

  /**
   * The template for the chat card summary of a bastion turn.
   * @type {string}
   */
  static TURN_TEMPLATE = "systems/dnd5e/templates/chat/bastion-turn-summary.hbs";

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Advance all bastions by a turn.
   * @returns {Promise<void>}
   */
  async advanceAllBastions() {
    // TODO: Should this advance game.time?
    const { duration } = game.settings.get("dnd5e", "bastionConfiguration");
    const haveBastions = game.actors.filter(a => (a.type === "character") && a.itemTypes.facility.length);
    for ( const actor of haveBastions ) await this.advanceAllFacilities(actor, { duration });
  }

  /* -------------------------------------------- */

  /**
   * Advance all the facilities of a given Actor by one bastion turn.
   * @param {Actor5e} actor                   The actor.
   * @param {object} [options]
   * @param {number} [options.duration=7]     The number of days the bastion turn spanned.
   * @param {boolean} [options.summary=true]  Print a chat message summary of the turn.
   * @returns {Promise<void>}
   */
  async advanceAllFacilities(actor, { duration=7, summary=true }={}) {
    const results = { orders: [], items: [], gold: 0 };
    for ( const facility of actor.itemTypes.facility ) {
      const { order, gold, items } = await this.advanceTurn(facility, { duration });
      if ( !order || (order === "maintain") ) continue;
      if ( gold ) results.gold += gold;
      if ( items ) results.items.push(...items);
      results.orders.push({ id: facility.id, order });
    }

    if ( summary ) {
      results.gold = { value: results.gold, claimed: false };
      const content = await this.#renderTurnSummary(actor, results);
      await ChatMessage.implementation.create({
        content,
        speaker: ChatMessage.implementation.getSpeaker({ actor }),
        flags: { dnd5e: { bastion: results } }
      });
    }
  }

  /* -------------------------------------------- */

  /**
   * Advance the given facility by one bastion turn.
   * @param {Item5e} facility              The facility.
   * @param {object} [options]
   * @param {number} [options.duration=7]  The number of days the bastion turn spanned.
   * @returns {Promise<BastionTurnResult>}
   */
  async advanceTurn(facility, { duration=7 }={}) {
    const { disabled, progress, type } = facility.system;

    // Case 1 - No order in progress.
    if ( !progress.max && !disabled ) {
      await facility.update({ "system.progress.order": "" });
      if ( type.value === "basic" ) return {}; // Basic facilities do nothing.
      return { order: "maintain" }; // Special facilities are considered to have been issued the maintain order.
    }

    const newProgress = Math.min(progress.value + duration, progress.max);

    // Case 2 - Order incomplete. Ongoing progress.
    if ( (newProgress < progress.max) && !disabled ) {
      await facility.update({ "system.progress.value": newProgress });
      return {};
    }

    // Case 3 - Order complete.
    const updates = { "system.progress": { value: 0, max: null, order: "" } };
    const { gold, items } = this.#evaluateOrder(facility, progress.order, updates);
    await facility.update(updates);
    return { gold, items, order: progress.order };
  }

  /* -------------------------------------------- */

  /**
   * Resolve a bastion attack against a given Actor's bastion.
   * @param {Actor5e} actor   The Actor.
   * @param {string} formula  The attack formula.
   * @param {object} [options]
   * @param {boolean} [options.summary=true]  Print a chat message summary of the attack.
   * @param {number} [options.threshold=1]    The maximum number on a die roll that is considered a defender death.
   * @returns {Promise<number>}               The number of defenders who died in the attack.
   */
  async resolveAttack(actor, formula, { summary=true, threshold=1 }={}) {
    const results = {};
    const roll = await Roll.create(formula).evaluate();
    const deaths = roll.dice.reduce((count, die) => {
      return count + die.results.filter(({ result, active }) => active && (result <= threshold)).length;
    }, 0);
    const defenders = this.#getDefenders(actor);
    if ( defenders.length ) results.deaths = Math.min(deaths, defenders.length);
    else results.undefended = true;
    if ( summary ) {
      const content = await this.#renderAttackSummary(actor, roll, results);
      await ChatMessage.implementation.create({
        content,
        speaker: ChatMessage.implementation.getSpeaker({ actor }),
        rolls: [roll],
        flags: { dnd5e: { bastion: results } }
      });
    }
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Attach interactivity to chat messages.
   * @param {ChatMessage5e} message  The chat message.
   * @param {HTMLElement} html       The rendered chat card element.
   * @internal
   */
  _activateChatListeners(message, html) {
    html.addEventListener("click", event => {
      const target = event.target.closest("[data-action]");
      if ( target ) this.#onChatAction(event, target, message);
    }, { passive: true });

    const actor = message.getAssociatedActor();
    if ( !actor?.isOwner ) return;

    html.querySelectorAll(".item-summary > li").forEach(async el => {
      const { uuid, quantity } = el.dataset;
      const item = await fromUuid(uuid);
      if ( !item ) return;
      el.draggable = true;
      el.addEventListener("dragstart", event => {
        this.#onDragItem(event, item, { "system.quantity": Number(quantity) });
      });
    });
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of an order.
   * @param {Item5e} facility  The facility.
   * @param {string} order     The order that was completed.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateOrder(facility, order, updates) {
    switch ( order ) {
      case "build": return this.#evaluateBuildOrder(facility, updates);
      case "craft": return this.#evaluateCraftOrder(facility, updates);
      case "enlarge": return this.#evaluateEnlargeOrder(facility, updates);
      case "harvest": return this.#evaluateHarvestOrder(facility, updates);
      case "repair": return this.#evaluateRepairOrder(facility, updates);
      case "trade": return this.#evaluateTradeOrder(facility, updates);
    }
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a build order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateBuildOrder(facility, updates) {
    const { building } = facility.system;
    updates["system.building.built"] = true;
    updates["system.size"] = building.size;
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a craft order.
   * @param {Item5e} facility          The facility.
   * @param {object} updates           Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateCraftOrder(facility, updates) {
    const { craft } = facility.system;
    updates["system.craft.item"] = null;
    return { items: [{ uuid: craft.item, quantity: 1 }] };
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of an enlarge order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateEnlargeOrder(facility, updates) {
    const { size } = facility.system;
    const sizes = Object.entries(CONFIG.DND5E.facilities.sizes).sort((a, b) => a.value - b.value);
    const index = sizes.findIndex(([key]) => key === size);
    const [next] = sizes[index + 1];
    updates["system.size"] = next;
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a harvest order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateHarvestOrder(facility, updates) {
    const { craft } = facility.system;
    return { items: [{ uuid: craft.item, quantity: craft.quantity }] };
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a repair order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateRepairOrder(facility, updates) {
    updates["system.disabled"] = false;
    return {};
  }

  /* -------------------------------------------- */

  /**
   * Evaluate the completion of a trade order.
   * @param {Item5e} facility  The facility.
   * @param {object} updates   Facility updates.
   * @returns {Omit<BastionTurnResult, "order">}
   */
  #evaluateTradeOrder(facility, updates) {
    const { trade } = facility.system;
    updates["system.trade.pending.operation"] = null;
    updates["system.trade.pending.creatures"] = [];
    updates["system.trade.pending.value"] = null;
    if ( !trade.pending.operation ) return {};

    if ( trade.pending.operation === "buy" ) {
      // Stocked facility
      if ( (trade.pending.value === null) && trade.pending.stocked ) updates["system.trade.stock.stocked"] = true;

      // Bought goods
      else if ( trade.pending.value !== null && !trade.pending.creatures.length ) {
        updates["system.trade.stock.value"] = Math.min(trade.stock.value + trade.pending.value, trade.stock.max);
      }
    } else if ( trade.pending.value !== null ) {
      // See OrderActivity#_finalizeTrade for creatures TODO
      // Sold goods
      let sold = trade.pending.value;
      if ( !trade.pending.creatures.length ) {
        updates["system.trade.stock.value"] = Math.max(0, trade.stock.value - trade.pending.value);
        sold = trade.stock.value - updates["system.trade.stock.value"];
      }

      return { gold: Math.floor(sold * ((trade.profit / 100) + 1)) };
    }

    return {};
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a list of defenders for the given Actor's bastion.
   * @param {Actor5e} actor  The actor.
   * @returns {{ facility: Item5e, uuid: string }[]}
   */
  #getDefenders(actor) {
    const allDefenders = [];
    for ( const facility of actor.itemTypes.facility ) {
      const { defenders, type } = facility.system;
      if ( (type.value === "special") && defenders.max ) {
        allDefenders.push(...defenders.value.map(uuid => ({ facility, uuid })));
      }
    }
    return allDefenders;
  }

  /* -------------------------------------------- */

  /**
   * Handle clicking action elements in chat cards.
   * @param {PointerEvent} event     The triggering event.
   * @param {HTMLElement} target     The action element.
   * @param {ChatMessage5e} message  The chat message.
   */
  #onChatAction(event, target, message) {
    const { action } = target.dataset;
    switch ( action ) {
      case "claim": this.#onClaimGold(message); break;
      case "resolve": this.#onResolveAttack(message); break;
      case "viewItem": this.#onViewItem(target); break;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle claiming gold from a bastion turn summary message.
   * @param {ChatMessage5e} message  The message.
   * @returns {Promise<ChatMessage5e|void>}
   */
  async #onClaimGold(message) {
    const results = message.getFlag("dnd5e", "bastion");
    const { gold } = results;
    const actor = message.getAssociatedActor();
    const { gp } = actor?.system?.currency ?? {};
    if ( !gold?.value || gold.claimed || (gp === undefined) ) return;
    await actor.update({ "system.currency.gp": gp + gold.value });
    gold.claimed = true;
    const content = await this.#renderTurnSummary(actor, results);
    return message.update({ content, flags: { dnd5e: { bastion: results } } });
  }

  /* -------------------------------------------- */

  /**
   * Handle dragging an item created as part of order completion.
   * @param {DragEvent} event    The initiating drag event.
   * @param {Item5e} item        The created item.
   * @param {object} [updates]   Updates to apply to the Item.
   */
  #onDragItem(event, item, updates={}) {
    // TODO: Need some way to mark the item as 'claimed' when it is dropped onto an Actor sheet.
    if ( !foundry.utils.isEmpty(updates) ) item.updateSource(updates);
    event.dataTransfer.setData("text/plain", JSON.stringify({ data: game.items.fromCompendium(item), type: "Item" }));
  }

  /* -------------------------------------------- */

  /**
   * Handle automatic resolution of a bastion attack via chat message.
   * @param {ChatMessage5e} message  The message.
   * @returns {Promise<ChatMessage5e|void>}
   */
  async #onResolveAttack(message) {
    const results = message.getFlag("dnd5e", "bastion") ?? {};
    const { deaths, undefended } = results;
    const actor = message.getAssociatedActor();
    if ( (!deaths && !undefended) || !actor ) return;

    if ( deaths ) {
      const defenders = this.#getDefenders(actor);
      const slain = [];
      for ( let i = 0; i < deaths; i++ ) {
        if ( !defenders.length ) break;
        const roll = await Roll.create(`1d${defenders.length}`).evaluate({ allowInteractive: false });
        const [defender] = defenders.splice(roll.total - 1, 1);
        slain.push(defender);
      }
      const updates = {};
      for ( const { facility, uuid } of slain ) {
        if ( !updates[facility.id] ) updates[facility.id] = [...facility.system.defenders.value];
        updates[facility.id].findSplice(a => a === uuid);
      }
      await actor.updateEmbeddedDocuments("Item", Object.entries(updates).map(([_id, value]) => {
        return { _id, "system.defenders.value": value };
      }));
    }

    let damaged;
    const defenders = this.#getDefenders(actor);
    if ( !defenders.length ) {
      const special = actor.itemTypes.facility.filter(f => (f.system.type.value === "special") && !f.system.disabled);
      if ( special.length ) {
        const roll = await Roll.create(`1d${special.length}`).evaluate({ allowInteractive: false });
        damaged = special[roll.total - 1];
        await damaged?.update({ "system.disabled": true });
      }
    }

    if ( damaged ) results.damaged = damaged.id;
    results.resolved = true;
    const content = await this.#renderAttackSummary(actor, message.rolls[0], results);
    return message.update({ content, flags: { dnd5e: { bastion: results } } });
  }

  /* -------------------------------------------- */

  /**
   * Handle viewing a created item.
   * @param {HTMLElement} target  The item element.
   * @returns {Promise}
   */
  async #onViewItem(target) {
    const { uuid } = target.dataset;
    const item = await fromUuid(uuid);
    return item?.sheet.render(true);
  }

  /* -------------------------------------------- */

  /**
   * Render a chat card summary for the bastion attack.
   * @param {Actor5e} actor                 The actor whose bastion was attacked.
   * @param {Roll} roll                     The bastion attack roll.
   * @param {object} [results]
   * @param {string} [results.damaged]      The ID of the facility damaged in the attack.
   * @param {number} [results.deaths]       The number of bastion defenders slain in the attack.
   * @param {boolean} [results.undefended]  If the bastion was undefended during the attack.
   * @param {boolean} [results.resolved]    Whether the attack has been automatically resolved.
   * @returns {Promise<string>}
   */
  async #renderAttackSummary(actor, roll, { damaged, deaths, undefended, resolved }={}) {
    const context = {};
    const plurals = new Intl.PluralRules(game.i18n.lang);
    const key = undefended ? "Undefended" : deaths ? `Deaths.${plurals.select(deaths)}` : "NoDeaths";
    context.description = game.i18n.format(`DND5E.Bastion.Attack.Result.${key}`, { deaths });
    context.roll = await roll.render();
    context.buttons = [];
    if ( !resolved && (deaths || undefended) ) {
      context.buttons.push({
        label: game.i18n.localize("DND5E.Bastion.Attack.Automatic"),
        icon: '<i class="fas fa-bolt"></i>',
        dataset: { action: "resolve" }
      });
    }
    if ( damaged ) {
      const facility = actor.items.get(damaged);
      if ( facility ) context.damaged = game.i18n.format("DND5E.Bastion.Attack.Result.Damaged", {
        link: facility.toAnchor().outerHTML
      });
    }
    return renderTemplate(this.constructor.ATTACK_TEMPLATE, context);
  }

  /* -------------------------------------------- */

  /**
   * Render a chat card summary for the bastion turn results.
   * @param {Actor5e} actor                                     The actor whose turn it was.
   * @param {object} results
   * @param {BastionTurnItem[]} results.items                   The items produced during the turn.
   * @param {{ value: number, claimed: boolean }} results.gold  Gold generated during the turn.
   * @param {{ id: string, order: string }[]} results.orders    Orders completed during the turn.
   * @returns {Promise<string>}
   */
  async #renderTurnSummary(actor, results) {
    const context = {};
    context.items = (await Promise.all(results.items.map(async ({ uuid, quantity }) => {
      const item = await fromUuid(uuid);
      if ( !item ) return null;
      const { name, img } = item;
      return { img, name, quantity, uuid };
    }))).filter(_ => _);
    context.orders = results.orders.map(({ id, order }) => {
      const facility = actor.items.get(id);
      return {
        name: facility.name,
        contentLink: facility.toAnchor().outerHTML,
        order: CONFIG.DND5E.facilities.orders[order]?.label
      };
    });
    context.supplements = [];
    if ( results.gold.value ) {
      context.supplements.push(`
        <strong>${game.i18n.localize("DND5E.CurrencyGP")}</strong>
        ${formatNumber(results.gold.value)}
        (${game.i18n.localize(`DND5E.Bastion.Gold.${results.gold.claimed ? "Claimed" : "Unclaimed"}`)})
      `);
    }
    context.buttons = [];
    if ( results.gold.value && !results.gold.claimed ) {
      context.buttons.push({
        label: game.i18n.localize("DND5E.Bastion.Gold.Claim"),
        icon: '<i class="fas fa-coins"></i>',
        dataset: { action: "claim" }
      });
    }
    return renderTemplate(this.constructor.TURN_TEMPLATE, context);
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Confirm the bastion turn should be advanced.
   * @returns {Promise<void>}
   */
  async confirmAdvance() {
    if ( !game.user.isGM ) return;
    const proceed = await foundry.applications.api.DialogV2.confirm({
      content: game.i18n.localize("DND5E.Bastion.Confirm"),
      rejectClose: false
    });
    if ( proceed ) return this.advanceAllBastions();
  }

  /* -------------------------------------------- */

  /**
   * Initialize the bastion UI.
   */
  initializeUI() {
    const turnButton = document.getElementById("bastion-turn");
    const { button, enabled } = game.settings.get("dnd5e", "bastionConfiguration");

    if ( !enabled || !button || !game.user.isGM) {
      turnButton?.remove();
      return;
    }

    if ( !turnButton ) {
      document.getElementById("controls")?.insertAdjacentHTML("afterend", `
        <button type="button" id="bastion-turn" data-action="bastionTurn" class="dnd5e2">
          <i class="fas fa-chess-rook"></i>
          <span>${game.i18n.localize("DND5E.Bastion.Action.BastionTurn")}</span>
        </button>
      `);
      document.getElementById("bastion-turn")?.addEventListener("click", this.confirmAdvance.bind(this));
    }
  }

  /* -------------------------------------------- */

  /**
   * Prompt the DM to resolve a bastion attack against a specific Actor.
   * @param {Actor5e} [actor]  The Actor.
   * @returns {Promise}
   */
  async promptAttack(actor) {
    if ( !game.user.isGM ) return;

    // Determine Actor by selected token.
    if ( !actor ) {
      const [token] = canvas.tokens.controlled;
      actor = token?.actor;
    }

    // Determine Actor by active window.
    if ( !actor && (ui.activeWindow instanceof ActorSheet) ) actor = ui.activeWindow.actor;

    if ( !actor ) {
      ui.notifications.warn("DND5E.Bastion.Attack.NoActorWarning", { localize: true });
      return;
    }

    const formula = await BastionAttackDialog.prompt(actor);
    if ( formula ) return this.resolveAttack(actor, formula);
  }
}

/* -------------------------------------------- */

const { StringField } = foundry.data.fields;

/**
 * A dialog for resolving bastion attacks.
 */
class BastionAttackDialog extends Dialog5e {
  constructor({ actor, ...options }={}) {
    super(options);
    this.#actor = actor;
  }

  /** @override */
  static DEFAULT_OPTIONS = {
    classes: ["bastion-attack"],
    window: {
      title: "DND5E.Bastion.Attack.Title",
      icon: "fas fa-chess-rook"
    },
    form: {
      handler: BastionAttackDialog.#handleFormSubmission
    },
    position: {
      width: 420
    },
    buttons: [{
      action: "resolve",
      label: "DND5E.Bastion.Attack.Resolve",
      icon: "fas fa-dice",
      default: true
    }]
  };

  /** @inheritDoc */
  static PARTS = {
    ...super.PARTS,
    content: {
      template: "systems/dnd5e/templates/apps/bastion-attack-dialog.hbs"
    }
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The Actor whose bastion is being attacked.
   * @type {Actor5e}
   */
  #actor;

  /**
   * The bastion attack formula.
   * @type {string|null}
   */
  get formula() {
    return this.#formula;
  }

  #formula = null;

  /** @override */
  get subtitle() {
    return this.#actor.name;
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  async _prepareContentContext(context, options) {
    context = await super._prepareContentContext(context, options);
    context.formula = {
      field: new StringField({ initial: "", label: "DND5E.Formula" }),
      name: "formula"
    };
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /**
   * Handle submission of the dialog.
   * @this {BastionAttackDialog}
   * @param {SubmitEvent} event          The form submission event.
   * @param {HTMLFormElement} form       The submitted form.
   * @param {FormDataExtended} formData  Data from the dialog.
   * @returns {Promise}
   */
  static #handleFormSubmission(event, form, formData) {
    this.#formula = formData.object.formula;
    return this.close({ dnd5e: { submitted: true } });
  }

  /* -------------------------------------------- */

  /** @override */
  _onClose(options={}) {
    if ( !options.dnd5e?.submitted ) this.#formula = null;
  }

  /* -------------------------------------------- */
  /*  Factory Methods                             */
  /* -------------------------------------------- */

  /**
   * Create the bastion attack prompt.
   * @param {Actor5e} actor      The Actor whose bastion is being attacked.
   * @returns {Promise<string>}  A promise that resolves to the input bastion attack formula.
   */
  static prompt(actor) {
    return new Promise(resolve => {
      const dialog = new this({ actor });
      dialog.addEventListener("close", () => resolve(dialog.formula), { once: true });
      dialog.render({ force: true });
    });
  }
}

var _module = /*#__PURE__*/Object.freeze({
  __proto__: null,
  SystemDocumentMixin: SystemDocumentMixin,
  SystemFlagsMixin: SystemFlagsMixin
});

// Document Classes

var documents = /*#__PURE__*/Object.freeze({
  __proto__: null,
  ActiveEffect5e: ActiveEffect5e,
  Actor5e: Actor5e,
  Bastion: Bastion,
  ChatMessage5e: ChatMessage5e,
  Combat5e: Combat5e,
  Combatant5e: Combatant5e,
  HitDice: HitDice,
  Item5e: Item5e,
  JournalEntryPage5e: JournalEntryPage5e,
  Proficiency: Proficiency,
  Scaling: Scaling,
  SelectChoices: SelectChoices,
  TokenDocument5e: TokenDocument5e,
  Trait: trait,
  User5e: User5e,
  activity: _module$s,
  advancement: _module$q,
  macro: macro,
  mixins: _module
});

/**
 * Valid `dropEffect` value (see https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/dropEffect).
 * @typedef {"copy"|"move"|"link"|"none"} DropEffectValue
 */

/**
 * Extension of core's DragDrop class to provide additional information used by the system. Will replace core's
 * version in the global namespace.
 */
class DragDrop5e extends DragDrop {

  /**
   * Drop effect used for current drag operation.
   * @type {DropEffectValue}
   */
  static dropEffect = null;

  /* -------------------------------------------- */

  /**
   * Stored drag event payload.
   * @type {{ data: any, event: DragEvent }|null}
   */
  static #payload = null;

  /* -------------------------------------------- */

  /** @inheritDoc */
  bind(html) {
    super.bind(html);

    // Add dragend event handler to the existing dragstart event
    if ( this.can("dragstart", this.dragSelector) ) {
      const draggables = html.querySelectorAll(this.dragSelector);
      for ( const el of draggables ) {
        el.ondragend = this._handleDragEnd.bind(this);
      }
    }

    return this;
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _handleDragStart(event) {
    await this.callback(event, "dragstart");
    if ( event.dataTransfer.items.length ) {
      event.stopPropagation();
      let data = event.dataTransfer.getData("application/json") || event.dataTransfer.getData("text/plain");
      try { data = JSON.parse(data); } catch(err) {}
      DragDrop5e.#payload = data ? { event, data } : null;
    } else {
      DragDrop5e.#payload = null;
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle the end of a drag workflow
   * @param {DragEvent} event   The drag event being handled
   * @private
   */
  async _handleDragEnd(event) {
    await this.callback(event, "dragend");
    DragDrop5e.dropEffect = null;
    DragDrop5e.#payload = null;
  }

  /* -------------------------------------------- */

  /**
   * Get the data payload for the current drag event.
   * @param {DragEvent} event
   * @returns {object|string|null}
   */
  static getPayload(event) {
    if ( !DragDrop5e.#payload?.data ) return null;
    return DragDrop5e.#payload.data;
  }
}

/**
 * Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @returns {Promise}      A Promise which resolves once the migration is completed
 */
async function migrateWorld({ bypassVersionCheck=false }={}) {
  const version = game.system.version;
  ui.notifications.info(game.i18n.format("MIGRATION.5eBegin", {version}), {permanent: true});

  const migrationData = await getMigrationData();
  await migrateSettings();

  // Migrate World Actors
  const actors = game.actors.map(a => [a, true])
    .concat(Array.from(game.actors.invalidDocumentIds).map(id => [game.actors.getInvalid(id), false]));
  for ( const [actor, valid] of actors ) {
    try {
      const flags = { bypassVersionCheck, persistSourceMigration: false };
      const source = valid ? actor.toObject() : game.data.actors.find(a => a._id === actor.id);
      const version = actor._stats.systemVersion;
      let updateData = migrateActorData(actor, source, migrationData, flags, { actorUuid: actor.uuid });
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Actor document ${actor.name}`);
        if ( flags.persistSourceMigration ) {
          updateData = foundry.utils.mergeObject(source, updateData, {inplace: false});
        }
        await actor.update(updateData, {
          enforceTypes: false, diff: valid && !flags.persistSourceMigration, render: false
        });
      }
      if ( actor.effects && actor.items && foundry.utils.isNewerVersion("3.0.3", version) ) {
        const deleteIds = _duplicatedEffects(actor);
        if ( deleteIds.size ) await actor.deleteEmbeddedDocuments("ActiveEffect", Array.from(deleteIds), {
          render: false
        });
      }
    } catch(err) {
      err.message = `Failed dnd5e system migration for Actor ${actor.name}: ${err.message}`;
      console.error(err);
    }
  }

  // Migrate World Items
  const items = game.items.map(i => [i, true])
    .concat(Array.from(game.items.invalidDocumentIds).map(id => [game.items.getInvalid(id), false]));
  for ( const [item, valid] of items ) {
    try {
      const flags = { bypassVersionCheck, persistSourceMigration: false };
      const source = valid ? item.toObject() : game.data.items.find(i => i._id === item.id);
      let updateData = migrateItemData(item, source, migrationData, flags);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Item document ${item.name}`);
        if ( flags.persistSourceMigration ) {
          if ( "effects" in updateData ) updateData.effects = source.effects.map(effect => foundry.utils.mergeObject(
            effect, updateData.effects.find(e => e._id === effect._id) ?? {}, { inplace: false, performDeletions: true }
          ));
          updateData = foundry.utils.mergeObject(source, updateData, { inplace: false, performDeletions: true });
        }
        await item.update(updateData, {
          enforceTypes: false, diff: valid && !flags.persistSourceMigration, render: false
        });
      }
    } catch(err) {
      err.message = `Failed dnd5e system migration for Item ${item.name}: ${err.message}`;
      console.error(err);
    }
  }

  // Migrate World Macros
  for ( const m of game.macros ) {
    try {
      const updateData = migrateMacroData(m.toObject(), migrationData);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Macro document ${m.name}`);
        await m.update(updateData, {enforceTypes: false, render: false});
      }
    } catch(err) {
      err.message = `Failed dnd5e system migration for Macro ${m.name}: ${err.message}`;
      console.error(err);
    }
  }

  // Migrate World Roll Tables
  for ( const table of game.tables ) {
    try {
      const updateData = migrateRollTableData(table.toObject(), migrationData);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating RollTable document ${table.name}`);
        await table.update(updateData, { enforceTypes: false, render: false });
      }
    } catch(err) {
      err.message = `Failed dnd5e system migration for RollTable ${table.name}: ${err.message}`;
      console.error(err);
    }
  }

  // Migrate Actor Override Tokens
  for ( const s of game.scenes ) {
    try {
      const updateData = migrateSceneData(s, migrationData);
      if ( !foundry.utils.isEmpty(updateData) ) {
        log(`Migrating Scene document ${s.name}`);
        await s.update(updateData, {enforceTypes: false, render: false});
      }
    } catch(err) {
      err.message = `Failed dnd5e system migration for Scene ${s.name}: ${err.message}`;
      console.error(err);
    }

    // Migrate ActorDeltas individually in order to avoid issues with ActorDelta bulk updates.
    for ( const token of s.tokens ) {
      if ( token.actorLink || !token.actor ) continue;
      try {
        const flags = { bypassVersionCheck, persistSourceMigration: false };
        const source = token.actor.toObject();
        let updateData = migrateActorData(token.actor, source, migrationData, flags, { actorUuid: token.actor.uuid });
        if ( !foundry.utils.isEmpty(updateData) ) {
          log(`Migrating ActorDelta document ${token.actor.name} [${token.delta.id}] in Scene ${s.name}`);
          if ( flags.persistSourceMigration ) {
            updateData = foundry.utils.mergeObject(source, updateData, { inplace: false });
          } else {
            // Workaround for core issue of bulk updating ActorDelta collections.
            ["items", "effects"].forEach(col => {
              for ( const [i, update] of (updateData[col] ?? []).entries() ) {
                const original = token.actor[col].get(update._id);
                updateData[col][i] = foundry.utils.mergeObject(original.toObject(), update, { inplace: false });
              }
            });
          }
          await token.actor.update(updateData, {
            enforceTypes: false, diff: !flags.persistSourceMigration, render: false
          });
        }
      } catch(err) {
        err.message = `Failed dnd5e system migration for ActorDelta [${token.id}]: ${err.message}`;
        console.error(err);
      }
    }
  }

  // Migrate World Compendium Packs
  for ( let p of game.packs ) {
    if ( _shouldMigrateCompendium(p) ) await migrateCompendium(p);
  }

  // Set the migration as complete
  game.settings.set("dnd5e", "systemMigrationVersion", game.system.version);
  ui.notifications.info(game.i18n.format("MIGRATION.5eComplete", {version}), {permanent: true});
}

/* -------------------------------------------- */

/**
 * Determine whether a compendium pack should be migrated during `migrateWorld`.
 * @param {Compendium} pack
 * @returns {boolean}
 */
function _shouldMigrateCompendium(pack) {
  // We only care about actor, item or scene migrations
  if ( !["Actor", "Item", "Scene"].includes(pack.documentName) ) return false;

  // World compendiums should all be migrated, system ones should never by migrated
  if ( pack.metadata.packageType === "world" ) return true;
  if ( pack.metadata.packageType === "system" ) return false;

  // Module compendiums should only be migrated if they don't have a download or manifest URL
  const module = game.modules.get(pack.metadata.packageName);
  return !module.download && !module.manifest;
}

/* -------------------------------------------- */

/**
 * Apply migration rules to all Documents within a single Compendium pack
 * @param {CompendiumCollection} pack       Pack to be migrated.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @param {boolean} [options.strict=false]  Migrate errors should stop the whole process.
 * @returns {Promise}
 */
async function migrateCompendium(pack, { bypassVersionCheck=false, strict=false }={}) {
  const documentName = pack.documentName;
  if ( !["Actor", "Item", "Scene"].includes(documentName) ) return;

  const migrationData = await getMigrationData();

  // Unlock the pack for editing
  const wasLocked = pack.locked;
  try {
    await pack.configure({locked: false});
    dnd5e.moduleArt.suppressArt = true;

    // Begin by requesting server-side data model migration and get the migrated content
    const documents = await pack.getDocuments();

    // Iterate over compendium entries - applying fine-tuned migration functions
    for ( let doc of documents ) {
      let updateData = {};
      try {
        const flags = { bypassVersionCheck, persistSourceMigration: false };
        const source = doc.toObject();
        switch ( documentName ) {
          case "Actor":
            updateData = migrateActorData(doc, source, migrationData, flags, { actorUuid: doc.uuid });
            if ( (documentName === "Actor") && source.effects && source.items
              && foundry.utils.isNewerVersion("3.0.3", source._stats.systemVersion) ) {
              const deleteIds = _duplicatedEffects(source);
              if ( deleteIds.size ) {
                if ( flags.persistSourceMigration ) source.effects = source.effects.filter(e => !deleteIds.has(e._id));
                else await doc.deleteEmbeddedDocuments("ActiveEffect", Array.from(deleteIds));
              }
            }
            break;
          case "Item":
            updateData = migrateItemData(doc, source, migrationData, flags);
            break;
          case "Scene":
            updateData = migrateSceneData(source, migrationData, flags);
            break;
        }

        // Save the entry, if data was changed
        if ( foundry.utils.isEmpty(updateData) ) continue;
        if ( flags.persistSourceMigration ) updateData = foundry.utils.mergeObject(source, updateData);
        await doc.update(updateData, { diff: !flags.persistSourceMigration });
        log(`Migrated ${documentName} document ${doc.name} in Compendium ${pack.collection}`);
      }

      // Handle migration failures
      catch(err) {
        err.message = `Failed dnd5e system migration for document ${doc.name} in pack ${pack.collection}: ${err.message}`;
        console.error(err);
        if ( strict ) throw err;
      }
    }

    log(`Migrated all ${documentName} documents from Compendium ${pack.collection}`);
  } finally {
    // Apply the original locked status for the pack
    await pack.configure({locked: wasLocked});
    dnd5e.moduleArt.suppressArt = false;
  }
}

/* -------------------------------------------- */

/**
 * Re-parents compendia from one top-level folder to another.
 * @param {string} from  The name of the source folder.
 * @param {string} to    The name of the destination folder.
 * @returns {Promise<Folder[]> | undefined}
 */
function reparentCompendiums(from, to) {
  const compendiumFolders = new Map();
  for ( const folder of game.folders ) {
    if ( folder.type !== "Compendium" ) continue;
    if ( folder.folder ) {
      let folders = compendiumFolders.get(folder.folder);
      if ( !folders ) {
        folders = [];
        compendiumFolders.set(folder.folder, folders);
      }
      folders.push(folder);
    }
    if ( folder.name === from ) from = folder;
    else if ( folder.name === to ) to = folder;
  }
  if ( !(from instanceof Folder) || !(to instanceof Folder) ) return;
  const config = game.settings.get("core", "compendiumConfiguration");

  // Re-parent packs directly under the source folder.
  Object.values(config).forEach(conf => {
    if ( conf.folder === from.id ) conf.folder = to.id;
  });

  game.settings.set("core", "compendiumConfiguration", config);

  // Re-parent folders directly under the source folder.
  const updates = (compendiumFolders.get(from) ?? []).map(f => ({ _id: f.id, folder: to.id }));
  return Folder.implementation.updateDocuments(updates).then(() => from.delete());
}

/* -------------------------------------------- */

/**
 * Update all compendium packs using the new system data model.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @param {boolean} [options.migrate=true]  Also perform a system migration before refreshing.
 */
async function refreshAllCompendiums(options) {
  for ( const pack of game.packs ) {
    await refreshCompendium(pack, options);
  }
}

/* -------------------------------------------- */

/**
 * Update all Documents in a compendium using the new system data model.
 * @param {CompendiumCollection} pack  Pack to refresh.
 * @param {object} [options={}]
 * @param {boolean} [options.bypassVersionCheck=false]  Bypass certain migration restrictions gated behind system
 *                                                      version stored in item stats.
 * @param {boolean} [options.migrate=true]  Also perform a system migration before refreshing.
 */
async function refreshCompendium(pack, { bypassVersionCheck, migrate=true }={}) {
  if ( !pack?.documentName ) return;
  if ( migrate ) {
    try {
      await migrateCompendium(pack, { bypassVersionCheck, strict: true });
    } catch( err ) {
      err.message = `Failed dnd5e system migration pack ${pack.collection}: ${err.message}`;
      console.error(err);
      return;
    }
  }

  dnd5e.moduleArt.suppressArt = true;
  const DocumentClass = CONFIG[pack.documentName].documentClass;
  const wasLocked = pack.locked;
  await pack.configure({locked: false});

  ui.notifications.info(`Beginning to refresh Compendium ${pack.collection}`);
  const documents = await pack.getDocuments();
  for ( const doc of documents ) {
    const data = doc.toObject();
    await doc.delete();
    await DocumentClass.create(data, {keepId: true, keepEmbeddedIds: true, pack: pack.collection});
  }
  await pack.configure({locked: wasLocked});
  dnd5e.moduleArt.suppressArt = false;
  ui.notifications.info(`Refreshed all documents from Compendium ${pack.collection}`);
}

/* -------------------------------------------- */

/**
 * Apply 'smart' AC migration to a given Actor compendium. This will perform the normal AC migration but additionally
 * check to see if the actor has armor already equipped, and opt to use that instead.
 * @param {CompendiumCollection|string} pack  Pack or name of pack to migrate.
 * @returns {Promise}
 */
async function migrateArmorClass(pack) {
  if ( typeof pack === "string" ) pack = game.packs.get(pack);
  if ( pack.documentName !== "Actor" ) return;
  const wasLocked = pack.locked;
  await pack.configure({locked: false});
  const actors = await pack.getDocuments();
  const updates = [];
  const armor = new Set(Object.keys(CONFIG.DND5E.armorTypes));

  for ( const actor of actors ) {
    try {
      log(`Migrating ${actor.name}...`);
      const src = actor.toObject();
      const update = {_id: actor.id};

      // Perform the normal migration.
      _migrateActorAC(src, update);
      // TODO: See if AC migration within DataModel is enough to handle this
      updates.push(update);

      // CASE 1: Armor is equipped
      const hasArmorEquipped = actor.itemTypes.equipment.some(e => {
        return armor.has(e.system.type.value) && e.system.equipped;
      });
      if ( hasArmorEquipped ) update["system.attributes.ac.calc"] = "default";

      // CASE 2: NPC Natural Armor
      else if ( src.type === "npc" ) update["system.attributes.ac.calc"] = "natural";
    } catch(e) {
      console.warn(`Failed to migrate armor class for Actor ${actor.name}`, e);
    }
  }

  await Actor.implementation.updateDocuments(updates, {pack: pack.collection});
  await pack.getDocuments(); // Force a re-prepare of all actors.
  await pack.configure({locked: wasLocked});
  log(`Migrated the AC of all Actors from Compendium ${pack.collection}`);
}

/* -------------------------------------------- */

/**
 * Migrate system settings to new data types.
 */
async function migrateSettings() {
  // Migrate Disable Experience Tracking to Leveling Mode
  const disableExperienceTracking = game.settings.storage.get("world")
    ?.find(s => s.key === "dnd5e.disableExperienceTracking")?.value;
  const levelingMode = game.settings.storage.get("world")?.find(s => s.key === "dnd5e.levelingMode")?.value;
  if ( (disableExperienceTracking !== undefined) && (levelingMode === undefined) ) {
    await game.settings.set("dnd5e", "levelingMode", "noxp");
  }
}

/* -------------------------------------------- */
/*  Document Type Migration Helpers             */
/* -------------------------------------------- */

/**
 * Migrate a single Actor document to incorporate latest data model changes
 * Return an Object of updateData to be applied
 * @param {Actor5e} actor               Full actor instance.
 * @param {object} actorData            The actor data object to update.
 * @param {object} [migrationData]      Additional data to perform the migration.
 * @param {object} [flags={}]           Track the needs migration flag.
 * @param {object} [options]
 * @param {string} [options.actorUuid]  The UUID of the actor.
 * @returns {object}                    The updateData to apply.
 */
function migrateActorData(actor, actorData, migrationData, flags={}, { actorUuid }={}) {
  const updateData = {};
  _migrateTokenImage(actorData, updateData);
  _migrateActorAC(actorData, updateData);
  _migrateActorMovementSenses(actorData, updateData);

  // Migrate embedded effects
  if ( actorData.effects ) {
    const effects = migrateEffects(actorData, migrationData);
    if ( foundry.utils.isNewerVersion("3.1.0", actorData._stats?.systemVersion) ) {
      migrateCopyActorTransferEffects(actorData, effects, { actorUuid });
    }
    if ( effects.length > 0 ) updateData.effects = effects;
  }

  // Set source rules version to Legacy
  if ( foundry.utils.isNewerVersion("4.0.0", actorData._stats?.systemVersion) || flags.bypassVersionCheck ) {
    updateData["system.source.rules"] = "2014";
  }

  // Migrate Owned Items
  if ( !actorData.items ) return updateData;
  const items = actor.items.reduce((arr, i) => {
    // Migrate the Owned Item
    const itemData = i instanceof CONFIG.Item.documentClass ? i.toObject() : i;
    const itemFlags = { bypassVersionCheck: flags.bypassVersionCheck ?? false, persistSourceMigration: false };
    let itemUpdate = migrateItemData(i, itemData, migrationData, itemFlags);

    if ( (itemData.type === "background") && (actorData.system?.details?.background !== itemData._id) ) {
      updateData["system.details.background"] = itemData._id;
    }

    // Prepared, Equipped, and Proficient for NPC actors
    if ( actorData.type === "npc" ) {
      if (foundry.utils.getProperty(itemData.system, "preparation.prepared") === false) itemUpdate["system.preparation.prepared"] = true;
      if (foundry.utils.getProperty(itemData.system, "equipped") === false) itemUpdate["system.equipped"] = true;
    }

    // Update the Owned Item
    if ( !foundry.utils.isEmpty(itemUpdate) ) {
      if ( itemFlags.persistSourceMigration ) {
        if ( "effects" in itemUpdate ) itemUpdate.effects = itemData.effects.map(effect => foundry.utils.mergeObject(
          effect, itemUpdate.effects.find(e => e._id === effect._id) ?? {}, { inplace: false, performDeletions: true }
        ));
        itemUpdate = foundry.utils.mergeObject(itemData, itemUpdate, { inplace: false, performDeletions: true });
        flags.persistSourceMigration = true;
      }
      arr.push({ ...itemUpdate, _id: itemData._id });
    }

    // Update tool expertise.
    if ( actorData.system.tools ) {
      const hasToolProf = itemData.system.type?.baseItem in actorData.system.tools;
      if ( (itemData.type === "tool") && (itemData.system.proficient > 1) && hasToolProf ) {
        updateData[`system.tools.${itemData.system.type.baseItem}.value`] = itemData.system.proficient;
      }
    }

    return arr;
  }, []);
  if ( items.length > 0 ) updateData.items = items;

  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate a single Item document to incorporate latest data model changes
 *
 * @param {Item5e} item             Full item instance.
 * @param {object} itemData         Item data to migrate.
 * @param {object} [migrationData]  Additional data to perform the migration.
 * @param {object} [flags={}]       Track the needs migration flag.
 * @returns {object}                The updateData to apply.
 */
function migrateItemData(item, itemData, migrationData, flags={}) {
  const updateData = {};
  _migrateDocumentIcon(itemData, updateData, migrationData);
  _migrateItemUses(item, itemData, updateData, flags);

  // Migrate embedded effects
  if ( itemData.effects ) {
    const riders = foundry.utils.getProperty(itemData, "flags.dnd5e.riders.effect");
    if ( riders?.length ) updateData["flags.dnd5e.riders.effect"] = riders;
    const effects = migrateEffects(itemData, migrationData, updateData);
    if ( riders?.length === updateData["flags.dnd5e.riders.effect"]?.length ) {
      delete updateData["flags.dnd5e.riders.effect"];
    }
    if ( effects.length > 0 ) updateData.effects = effects;
  }

  // Set source rules version to Legacy
  if ( foundry.utils.isNewerVersion("4.0.0", itemData._stats?.systemVersion) || flags.bypassVersionCheck ) {
    updateData["system.source.rules"] = "2014";
    if ( Object.hasOwn(item.system, "identifier") && !itemData.system?.identifier ) {
      updateData["system.identifier"] = item.identifier;
    }
  }

  // Migrate properties
  const migratedProperties = foundry.utils.getProperty(itemData, "flags.dnd5e.migratedProperties");
  if ( migratedProperties?.length ) {
    flags.persistSourceMigration = true;
    const properties = new Set(foundry.utils.getProperty(itemData, "system.properties") ?? [])
      .union(new Set(migratedProperties));
    updateData["system.properties"] = Array.from(properties);
    updateData["flags.dnd5e.-=migratedProperties"] = null;
  }

  if ( foundry.utils.getProperty(itemData, "flags.dnd5e.persistSourceMigration") ) {
    flags.persistSourceMigration = true;
    updateData["flags.dnd5e.-=persistSourceMigration"] = null;
  }

  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate any active effects attached to the provided parent.
 * @param {object} parent            Data of the parent being migrated.
 * @param {object} [migrationData]   Additional data to perform the migration.
 * @param {object} [itemUpdateData]  Update data for the item to apply changes back to item.
 * @returns {object[]}               Updates to apply on the embedded effects.
 */
function migrateEffects(parent, migrationData, itemUpdateData) {
  if ( !parent.effects ) return [];
  return parent.effects.reduce((arr, e) => {
    const effectData = e instanceof CONFIG.ActiveEffect.documentClass ? e.toObject() : e;
    let effectUpdate = migrateEffectData(effectData, migrationData, { parent });
    if ( effectData.flags?.dnd5e?.rider ) {
      itemUpdateData["flags.dnd5e.riders.effect"] ??= [];
      itemUpdateData["flags.dnd5e.riders.effect"].push(effectData._id);
      effectUpdate["flags.dnd5e.-=rider"] = null;
    }
    if ( !foundry.utils.isEmpty(effectUpdate) ) {
      effectUpdate._id = effectData._id;
      arr.push(foundry.utils.expandObject(effectUpdate));
    }
    return arr;
  }, []);
}

/* -------------------------------------------- */

/**
 * Migrates transfer effects on items belonging to this actor to "real" effects on the actor.
 * @param {object} actor                 The parent actor.
 * @param {object[]} effects             An array of new effects to add.
 * @param {object} [options]             Additional options.
 * @param {string} [options.actorUuid]   UUID of the parent actor
 */
const migrateCopyActorTransferEffects = function(actor, effects, { actorUuid }={}) {
  if ( !actor.items ) return;

  for ( const item of actor.items ) {
    for ( const effect of item.effects ) {
      if ( !effect.transfer ) continue;
      if ( !isSpellOrScroll(item) ) continue;
      if ( effect.disabled ) continue;

      const newEffect = foundry.utils.deepClone(effect);
      newEffect.transfer = false;
      if ( actorUuid ) newEffect.origin = `${actorUuid}.Item.${item._id}.ActiveEffect.${effect._id}`;
      delete newEffect._id;
      effects.push(newEffect);
    }
  }
};

/* -------------------------------------------- */

/**
 * Migrate the provided active effect data.
 * @param {object} effect            Effect data to migrate.
 * @param {object} [migrationData]   Additional data to perform the migration.
 * @param {object} [options]         Additional options.
 * @param {object} [options.parent]  Parent of this effect.
 * @returns {object}                 The updateData to apply.
 */
const migrateEffectData = function(effect, migrationData, { parent }={}) {
  const updateData = {};
  _migrateDocumentIcon(effect, updateData, {...migrationData, field: "img"});
  _migrateEffectArmorClass(effect, updateData);
  if ( foundry.utils.isNewerVersion("3.1.0", effect._stats?.systemVersion ?? parent?._stats?.systemVersion) ) {
    _migrateTransferEffect(effect, parent, updateData);
  }
  return updateData;
};

/* -------------------------------------------- */

/**
 * Migrate a single Macro document to incorporate latest data model changes.
 * @param {object} macro            Macro data to migrate
 * @param {object} [migrationData]  Additional data to perform the migration
 * @returns {object}                The updateData to apply
 */
const migrateMacroData = function(macro, migrationData) {
  const updateData = {};
  _migrateDocumentIcon(macro, updateData, migrationData);
  _migrateMacroCommands(macro, updateData);
  return updateData;
};

/* -------------------------------------------- */

/**
 * Migrate a single RollTable document to incorporate the latest data model changes.
 * @param {object} table            Roll table data to migrate.
 * @param {object} [migrationData]  Additional data to perform the migration.
 * @returns {object}                The update delta to apply.
 */
function migrateRollTableData(table, migrationData) {
  const updateData = {};
  _migrateDocumentIcon(table, updateData, migrationData);
  if ( !table.results?.length ) return updateData;
  const results = table.results.reduce((arr, result) => {
    const resultUpdate = {};
    _migrateDocumentIcon(result, resultUpdate, migrationData);
    if ( !foundry.utils.isEmpty(resultUpdate) ) {
      resultUpdate._id = result._id;
      arr.push(foundry.utils.expandObject(resultUpdate));
    }
    return arr;
  }, []);
  if ( results.length ) updateData.results = results;
  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate a single Scene document to incorporate changes to the data model of its actor data overrides
 * Return an Object of updateData to be applied
 * @param {object} scene            The Scene data to Update
 * @param {object} [migrationData]  Additional data to perform the migration
 * @returns {object}                The updateData to apply
 */
const migrateSceneData = function(scene, migrationData) {
  const tokens = scene.tokens.reduce((arr, token) => {
    const t = token instanceof foundry.abstract.DataModel ? token.toObject() : token;
    const update = {};
    _migrateTokenImage(t, update);
    if ( !game.actors.has(t.actorId) ) update.actorId = null;
    if ( !foundry.utils.isEmpty(update) ) arr.push({ ...update, _id: t._id });
    return arr;
  }, []);
  if ( tokens.length ) return { tokens };
  return {};
};

/* -------------------------------------------- */

/**
 * Fetch bundled data for large-scale migrations.
 * @returns {Promise<object>}  Object mapping original system icons to their core replacements.
 */
const getMigrationData = async function() {
  const data = {};
  try {
    const icons = await fetch("systems/dnd5e/json/icon-migration.json");
    const spellIcons = await fetch("systems/dnd5e/json/spell-icon-migration.json");
    data.iconMap = {...await icons.json(), ...await spellIcons.json()};
  } catch(err) {
    console.warn(`Failed to retrieve icon migration data: ${err.message}`);
  }
  return data;
};

/* -------------------------------------------- */
/*  Low level migration utilities
/* -------------------------------------------- */

/**
 * Identify effects that might have been duplicated when legacyTransferral was disabled.
 * @param {object} parent   Data of the actor being migrated.
 * @returns {Set<string>}   IDs of effects to delete from the actor.
 * @private
 */
function _duplicatedEffects(parent) {
  const deleteIds = new Set();
  for ( const item of parent.items ) {
    for ( const effect of item.effects ?? [] ) {
      if ( !effect.transfer ) continue;
      const match = parent.effects.find(t => {
        const diff = foundry.utils.diffObject(t, effect);
        return t.origin?.endsWith(`Item.${item._id}`) && !("changes" in diff) && !deleteIds.has(t._id);
      });
      if ( match ) deleteIds.add(match._id);
    }
  }
  return deleteIds;
}

/* -------------------------------------------- */

/**
 * Migrate the actor attributes.ac.value to the new ac.flat override field.
 * @param {object} actorData   Actor data being migrated.
 * @param {object} updateData  Existing updates being applied to actor. *Will be mutated.*
 * @returns {object}           Modified version of update data.
 * @private
 */
function _migrateActorAC(actorData, updateData) {
  const ac = actorData.system?.attributes?.ac;
  // If the actor has a numeric ac.value, then their AC has not been migrated to the auto-calculation schema yet.
  if ( Number.isNumeric(ac?.value) ) {
    updateData["system.attributes.ac.flat"] = parseInt(ac.value);
    updateData["system.attributes.ac.calc"] = actorData.type === "npc" ? "natural" : "flat";
    updateData["system.attributes.ac.-=value"] = null;
    return updateData;
  }

  // Migrate ac.base in custom formulas to ac.armor
  if ( (typeof ac?.formula === "string") && ac?.formula.includes("@attributes.ac.base") ) {
    updateData["system.attributes.ac.formula"] = ac.formula.replaceAll("@attributes.ac.base", "@attributes.ac.armor");
  }

  // Protect against string values created by character sheets or importers that don't enforce data types
  if ( (typeof ac?.flat === "string") && Number.isNumeric(ac.flat) ) {
    updateData["system.attributes.ac.flat"] = parseInt(ac.flat);
  }

  // Remove invalid AC formula strings.
  if ( ac?.formula ) {
    try {
      const roll = new Roll(ac.formula);
      roll.evaluateSync();
    } catch( e ) {
      updateData["system.attributes.ac.formula"] = "";
    }
  }

  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate the actor movement & senses to replace `0` with `null`.
 * @param {object} actorData   Actor data being migrated.
 * @param {object} updateData  Existing updates being applied to actor. *Will be mutated.*
 * @returns {object}           Modified version of update data.
 * @private
 */
function _migrateActorMovementSenses(actorData, updateData) {
  if ( actorData._stats?.systemVersion && foundry.utils.isNewerVersion("2.4.0", actorData._stats.systemVersion) ) {
    for ( const key of Object.keys(CONFIG.DND5E.movementTypes) ) {
      const keyPath = `system.attributes.movement.${key}`;
      if ( foundry.utils.getProperty(actorData, keyPath) === 0 ) updateData[keyPath] = null;
    }
    for ( const key of Object.keys(CONFIG.DND5E.senses) ) {
      const keyPath = `system.attributes.senses.${key}`;
      if ( foundry.utils.getProperty(actorData, keyPath) === 0 ) updateData[keyPath] = null;
    }
  }
  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate any system token images from PNG to WEBP.
 * @param {object} actorData    Actor or token data to migrate.
 * @param {object} updateData   Existing update to expand upon.
 * @returns {object}            The updateData to apply
 * @private
 */
function _migrateTokenImage(actorData, updateData) {
  const oldSystemPNG = /^systems\/dnd5e\/tokens\/([a-z]+)\/([A-z]+).png$/;
  for ( const path of ["texture.src", "prototypeToken.texture.src"] ) {
    const v = foundry.utils.getProperty(actorData, path);
    if ( oldSystemPNG.test(v) ) {
      const [type, fileName] = v.match(oldSystemPNG).slice(1);
      updateData[path] = `systems/dnd5e/tokens/${type}/${fileName}.webp`;
    }
  }
  return updateData;
}

/* -------------------------------------------- */

/**
 * Convert system icons to use bundled core webp icons.
 * @param {object} document                                 Document data to migrate
 * @param {object} updateData                               Existing update to expand upon
 * @param {object} [migrationData={}]                       Additional data to perform the migration
 * @param {Object<string, string>} [migrationData.iconMap]  A mapping of system icons to core foundry icons
 * @param {string} [migrationData.field]                    The document field to migrate
 * @returns {object}                                        The updateData to apply
 * @private
 */
function _migrateDocumentIcon(document, updateData, {iconMap, field="img"}={}) {
  let path = document?.[field];
  if ( path && iconMap ) {
    if ( path.startsWith("/") || path.startsWith("\\") ) path = path.substring(1);
    const rename = iconMap[path];
    if ( rename ) updateData[field] = rename;
  }
  return updateData;
}

/* -------------------------------------------- */

/**
 * Change active effects that target AC.
 * @param {object} effect      Effect data to migrate.
 * @param {object} updateData  Existing update to expand upon.
 * @returns {object}           The updateData to apply.
 */
function _migrateEffectArmorClass(effect, updateData) {
  let containsUpdates = false;
  const changes = (effect.changes || []).map(c => {
    if ( c.key !== "system.attributes.ac.base" ) return c;
    c.key = "system.attributes.ac.armor";
    containsUpdates = true;
    return c;
  });
  if ( containsUpdates ) updateData.changes = changes;
  return updateData;
}

/* -------------------------------------------- */

/**
 * Move `uses.value` to `uses.spent` for items.
 * @param {Item5e} item        Full item instance.
 * @param {object} itemData    Item data to migrate.
 * @param {object} updateData  Existing update to expand upon.
 * @param {object} flags       Track the needs migration flag.
 */
function _migrateItemUses(item, itemData, updateData, flags) {
  const value = foundry.utils.getProperty(itemData, "flags.dnd5e.migratedUses");
  const max = foundry.utils.getProperty(item, "system.uses.max");
  if ( (value !== undefined) && (max !== undefined) && Number.isNumeric(value) && Number.isNumeric(max) ) {
    foundry.utils.setProperty(updateData, "system.uses.spent", parseInt(max) - parseInt(value));
    flags.persistSourceMigration = true;
  }
  if ( value !== undefined ) updateData["flags.dnd5e.-=migratedUses"] = null;
}

/* -------------------------------------------- */

/**
 * Disable transfer on effects on spell items
 * @param {object} effect      Effect data to migrate.
 * @param {object} parent      The parent of this effect.
 * @param {object} updateData  Existing update to expand upon.
 * @returns {object}           The updateData to apply.
 */
function _migrateTransferEffect(effect, parent, updateData) {
  if ( !effect.transfer ) return updateData;
  if ( !isSpellOrScroll(parent) ) return updateData;

  updateData.transfer = false;
  updateData.disabled = true;
  updateData["duration.startTime"] = null;
  updateData["duration.startRound"] = null;
  updateData["duration.startTurn"] = null;

  return updateData;
}

/* -------------------------------------------- */

/**
 * Migrate macros from the old 'dnd5e.rollItemMacro' and 'dnd5e.macros' commands to the new location.
 * @param {object} macro       Macro data to migrate.
 * @param {object} updateData  Existing update to expand upon.
 * @returns {object}           The updateData to apply.
 */
function _migrateMacroCommands(macro, updateData) {
  if ( macro.command.includes("game.dnd5e.rollItemMacro") ) {
    updateData.command = macro.command.replaceAll("game.dnd5e.rollItemMacro", "dnd5e.documents.macro.rollItem");
  } else if ( macro.command.includes("game.dnd5e.macros.") ) {
    updateData.command = macro.command.replaceAll("game.dnd5e.macros.", "dnd5e.documents.macro.");
  }
  return updateData;
}

/* -------------------------------------------- */

/**
 * A general tool to purge flags from all documents in a Compendium pack.
 * @param {CompendiumCollection} pack   The compendium pack to clean.
 * @private
 */
async function purgeFlags(pack) {
  const cleanFlags = flags => {
    const flags5e = flags.dnd5e || null;
    return flags5e ? {dnd5e: flags5e} : {};
  };
  await pack.configure({locked: false});
  const content = await pack.getDocuments();
  for ( let doc of content ) {
    const update = {flags: cleanFlags(doc.flags)};
    if ( pack.documentName === "Actor" ) {
      update.items = doc.items.map(i => {
        i.flags = cleanFlags(i.flags);
        return i;
      });
    }
    await doc.update(update, {recursive: false});
    log(`Purged flags from ${doc.name}`);
  }
  await pack.configure({locked: true});
}

/* -------------------------------------------- */

/**
 * Returns whether given item data represents either a spell item or a spell scroll consumable
 * @param {object} item  The item data.
 * @returns {boolean}
 */
function isSpellOrScroll(item) {
  if ( (item.type === "consumable") && (item.system.type.value === "scroll") ) return true;
  return item.type === "spell";
}

var migrations = /*#__PURE__*/Object.freeze({
  __proto__: null,
  getMigrationData: getMigrationData,
  migrateActorData: migrateActorData,
  migrateArmorClass: migrateArmorClass,
  migrateCompendium: migrateCompendium,
  migrateCopyActorTransferEffects: migrateCopyActorTransferEffects,
  migrateEffectData: migrateEffectData,
  migrateEffects: migrateEffects,
  migrateItemData: migrateItemData,
  migrateMacroData: migrateMacroData,
  migrateRollTableData: migrateRollTableData,
  migrateSceneData: migrateSceneData,
  migrateSettings: migrateSettings,
  migrateWorld: migrateWorld,
  purgeFlags: purgeFlags,
  refreshAllCompendiums: refreshAllCompendiums,
  refreshCompendium: refreshCompendium,
  reparentCompendiums: reparentCompendiums
});

/* -------------------------------------------- */
/*  Module Data                                 */
/* -------------------------------------------- */

/**
 * Scan module manifests for any data that should be integrated into the system configuration.
 */
function registerModuleData() {
  log("Registering Module Data", { level: "groupCollapsed" });
  for ( const manifest of [game.system, ...game.modules.filter(m => m.active), game.world] ) {
    try {
      const complete = registerMethods.map(m => m(manifest)).filter(r => r);
      if ( complete.length ) log(`Registered ${manifest.title} data: ${complete.join(", ")}`);
    } catch(err) {
      log(`Error registering ${manifest.title}\n`, { extras: [err.message], level: "error" });
    }
  }
  console.groupEnd();
}

const registerMethods = [registerSourceBooks, registerSpellLists];

/* -------------------------------------------- */

/**
 * Register package source books from `flags.dnd5e.sourceBooks`.
 * @param {Module|System|World} manifest  Manifest from which to register data.
 * @returns {string|void}                 Description of the data registered.
 */
function registerSourceBooks(manifest) {
  if ( !manifest.flags.dnd5e?.sourceBooks ) return;
  Object.assign(CONFIG.DND5E.sourceBooks, manifest.flags.dnd5e.sourceBooks);
  return "source books";
}

/* -------------------------------------------- */

/**
 * Register package spell lists from `flags.dnd5e.spellLists`.
 * @param {Module|System|World} manifest  Manifest from which to register data.
 * @returns {string|void}                 Description of the data registered.
 */
function registerSpellLists(manifest) {
  if ( foundry.utils.getType(manifest.flags.dnd5e?.spellLists) !== "Array" ) return;
  manifest.flags.dnd5e.spellLists.forEach(uuid => dnd5e.registry.spellLists.register(uuid));
  return "spell lists";
}

/* -------------------------------------------- */
/*  Compendium Packs                            */
/* -------------------------------------------- */

/**
 * Apply any changes to compendium packs during the setup hook.
 */
function setupModulePacks() {
  log("Setting Up Compendium Packs", { level: "groupCollapsed" });
  for ( const pack of game.packs ) {
    if ( pack.metadata.type === "Item" ) pack.applicationClass = ItemCompendium5e;
    try {
      const complete = setupMethods.map(m => m(pack)).filter(r => r);
      if ( complete.length ) log(`Finished setting up ${pack.metadata.label}: ${complete.join(", ")}`);
    } catch(err) {
      log(`Error setting up ${pack.title}\n`, { extras: [err.message], level: "error" });
    }
  }
  if ( sortingChanged ) game.settings.set("core", "collectionSortingModes", collectionSortingModes);
  console.groupEnd();
}

const setupMethods = [setupPackDisplay, setupPackSorting];

/* -------------------------------------------- */

/**
 * Set application based on `flags.dnd5e.display`.
 * @param {Compendium} pack  Pack to set up.
 * @returns {string|void}    Description of the step.
 */
function setupPackDisplay(pack) {
  const display = pack.metadata.flags.display ?? pack.metadata.flags.dnd5e?.display;
  if ( display !== "table-of-contents" ) return;
  pack.applicationClass = TableOfContentsCompendium;
  return "table of contents";
}

/* -------------------------------------------- */

let collectionSortingModes;
let sortingChanged = false;

/**
 * Set default sorting order based on `flags.dnd5e.sorting`.
 * @param {Compendium} pack  Pack to set up.
 * @returns {string|void}    Description of the step.
 */
function setupPackSorting(pack) {
  collectionSortingModes ??= game.settings.get("core", "collectionSortingModes") ?? {};
  if ( !pack.metadata.flags.dnd5e?.sorting || collectionSortingModes[pack.metadata.id] ) return;
  collectionSortingModes[pack.metadata.id] = pack.metadata.flags.dnd5e.sorting;
  sortingChanged = true;
  return "default sorting";
}

/* -------------------------------------------- */
/*  Enchantments                                */
/* -------------------------------------------- */

class EnchantmentRegisty {
  /**
   * Registration of enchanted items mapped to a specific enchantment source. The map is keyed by the UUID of
   * enchant activities while the set contains UUID of applied enchantment active effects.
   * @type {Map<string, Set<string>>}
   */
  static #appliedEnchantments = new Map();

  /* -------------------------------------------- */

  /**
   * Fetch the tracked enchanted items.
   * @param {string} uuid  UUID of an activity or item.
   * @returns {ActiveEffect5e[]}
   */
  static applied(uuid) {
    const source = fromUuidSync(uuid);
    if ( source instanceof Item ) {
      return source.system.activities?.getByType("enchant")
        .map(a => EnchantmentRegisty.applied(a.uuid))
        .flat() ?? [];
    }
    return Array.from(EnchantmentRegisty.#appliedEnchantments.get(uuid) ?? [])
      .map(uuid => fromUuidSync(uuid))
      .filter(effect => effect?.isAppliedEnchantment);
  }

  /* -------------------------------------------- */

  /**
   * Add a new enchantment effect to the list of tracked enchantments. Will not track enchanted items in compendiums.
   * @param {string} source     UUID of the active effect origin for the enchantment.
   * @param {string} enchanted  UUID of the enchantment to track.
   */
  static track(source, enchanted) {
    if ( enchanted.startsWith("Compendium.") ) return;
    if ( !EnchantmentRegisty.#appliedEnchantments.has(source) ) {
      EnchantmentRegisty.#appliedEnchantments.set(source, new Set());
    }
    EnchantmentRegisty.#appliedEnchantments.get(source).add(enchanted);
  }

  /* -------------------------------------------- */

  /**
   * Stop tracking an enchantment.
   * @param {string} source     UUID of the active effect origin for the enchantment.
   * @param {string} enchanted  UUID of the enchantment to stop tracking.
   */
  static untrack(source, enchanted) {
    EnchantmentRegisty.#appliedEnchantments.get(source)?.delete(enchanted);
  }
}

/* -------------------------------------------- */
/*  Item Registry                               */
/* -------------------------------------------- */

class ItemRegistry {
  constructor(itemsType) {
    this.#itemType = itemsType;
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * @typedef {object} RegisteredItemData
   * @property {string} name        Name of the item.
   * @property {string} identifier  Item identifier.
   * @property {string} img         Item's icon.
   * @property {string[]} sources   UUIDs of different compendium items matching this identifier.
   */

  /**
   * Items grouped by identifiers.
   * @type {Map<string, RegisteredItemData>}
   */
  #items = new Map();

  /* -------------------------------------------- */

  /**
   * Type of item represented by this registry.
   * @type {string}
   */
  #itemType;

  /* -------------------------------------------- */

  /**
   * Has initial loading been completed?
   * @type {number}
   */
  #status = ItemRegistry.#STATUS_STATES.NONE;

  /**
   * Possible preparation states for the item registry.
   * @enum {number}
   */
  static #STATUS_STATES = Object.freeze({
    NONE: 0,
    LOADING: 1,
    READY: 2
  });

  /* -------------------------------------------- */

  /**
   * Choices object.
   * @type {Record<string, string>}
   */
  get choices() {
    return this.options.reduce((obj, { value, label }) => {
      obj[value] = label;
      return obj;
    }, {});
  }

  /* -------------------------------------------- */

  /**
   * All items formatted for a select input.
   * @type {FormSelectOption[]}
   */
  get options() {
    return Array.from(this.#items.entries())
      .map(([value, data]) => ({ value, label: data.name }))
      .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang));
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Get information on a single item based on its identifier.
   * @param {string} identifier
   * @returns {RegisteredItemData|void}
   */
  get(identifier) {
    return this.#items.get(identifier);
  }

  /* -------------------------------------------- */

  /**
   * Scan compendium packs to register matching items of this type.
   */
  async initialize() {
    if ( this.#status > ItemRegistry.#STATUS_STATES.NONE ) return;
    RegistryStatus.set(this.#itemType, false);
    if ( game.modules.get("babele")?.active && (game.babele?.initialized === false) ) {
      Hooks.once("babele.ready", () => this.initialize());
      return;
    } else if ( !game.ready ) {
      Hooks.once("ready", () => this.initialize());
      return;
    }
    this.#status = ItemRegistry.#STATUS_STATES.LOADING;

    const indexes = await CompendiumBrowser.fetch(Item, {
      types: new Set([this.#itemType]),
      indexFields: new Set(["system.identifier"]),
      sort: false
    });
    for ( const item of indexes ) {
      const identifier = item.system?.identifier ?? slugify(item.name, { strict: true });
      if ( !this.#items.has(identifier) ) this.#items.set(identifier, { sources: [] });
      const itemData = this.#items.get(identifier);
      itemData.name = item.name;
      itemData.img = item.img;
      itemData.identifier = identifier;
      itemData.sources.push(item.uuid);
    }

    this.#status = ItemRegistry.#STATUS_STATES.READY;
    RegistryStatus.set(this.#itemType, true);
  }
}

/* -------------------------------------------- */
/*  Message Rolls                               */
/* -------------------------------------------- */

class MessageRegistry {
  /**
   * Registration of roll chat messages that originated at a specific message. The map is keyed by the ID of
   * the originating message and contains sets of IDs for each roll type.
   * @type {Map<string, Map<string, Set<string>>}
   */
  static #messages = new Map();

  /* -------------------------------------------- */

  /**
   * Fetch roll messages for an origin message, in chronological order.
   * @param {string} origin  ID of the origin message.
   * @param {string} [type]  Type of roll messages to fetch.
   * @returns {ChatMessage5e[]}
   */
  static get(origin, type) {
    const originMap = MessageRegistry.#messages.get(origin);
    if ( !originMap ) return [];
    let ids;
    if ( type ) ids = Array.from(originMap.get(type) ?? []);
    else ids = Array.from(originMap.values()).map(v => Array.from(v)).flat();
    return ids
      .map(id => game.messages.get(id))
      .filter(m => m)
      .sort((lhs, rhs) => lhs.timestamp - rhs.timestamp);
  }

  /* -------------------------------------------- */

  /**
   * Add a new roll message to the registry.
   * @param {ChatMessage5e} message  Message to add to the registry.
   */
  static track(message) {
    const origin = message.getFlag("dnd5e", "originatingMessage");
    const type = message.getFlag("dnd5e", "roll.type");
    if ( !origin || !type ) return;
    if ( !MessageRegistry.#messages.has(origin) ) MessageRegistry.#messages.set(origin, new Map());
    const originMap = MessageRegistry.#messages.get(origin);
    if ( !originMap.has(type) ) originMap.set(type, new Set());
    originMap.get(type).add(message.id);
  }

  /* -------------------------------------------- */

  /**
   * Remove a roll message to the registry.
   * @param {ChatMessage5e} message  Message to remove from the registry.
   */
  static untrack(message) {
    const origin = message.getFlag("dnd5e", "originatingMessage");
    const type = message.getFlag("dnd5e", "roll.type");
    MessageRegistry.#messages.get(origin)?.get(type)?.delete(message.id);
  }
}

/* -------------------------------------------- */
/*  Spell Lists                                 */
/* -------------------------------------------- */

class SpellListRegistry {
  /**
   * Spell lists organized by the UUID of a spell they contain.
   * @type {Map<string, Set<SpellList>>}
   */
  static #bySpell = new Map();

  /* -------------------------------------------- */

  /**
   * Registration of spell lists grouped by type and identifier.
   * @type {Map<string, Map<string, SpellList>>}
   */
  static #byType = new Map();

  /* -------------------------------------------- */

  /**
   * UUIDs of spell lists in the process of being loaded.
   * @type {Set<string>}
   */
  static #loading = new Set();

  /* -------------------------------------------- */

  /**
   * Options for each registered spell list, grouped by type.
   * @type {FormSelectOption[]}
   */
  static get options() {
    return Object.entries(CONFIG.DND5E.spellListTypes).map(([type, group]) => {
      const lists = this.#byType.get(type);
      if ( !lists ) return [];
      return Array.from(lists.entries())
        .map(([value, list]) => ({ value, label: list.name, group, type }))
        .sort((lhs, rhs) => lhs.label.localeCompare(rhs.label, game.i18n.lang));
    }).flat();
  }

  /* -------------------------------------------- */

  /**
   * Have spell lists finished loading?
   * @type {boolean}
   */
  static get ready() {
    return this.#loading.size === 0;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Retrieve a list of spell lists a spell belongs to.
   * @param {string} uuid  UUID of a spell item.
   * @returns {Set<SpellList>}
   */
  static forSpell(uuid) {
    return SpellListRegistry.#bySpell.get(uuid) ?? new Set();
  }

  /* -------------------------------------------- */

  /**
   * Retrieve a specific spell list from the registry.
   * @param {string} type        Type of list as defined in `CONFIG.DND5E.spellListTypes`.
   * @param {string} identifier  Identifier of the specific spell list.
   * @returns {SpellList|null}
   */
  static forType(type, identifier) {
    return SpellListRegistry.#byType.get(type)?.get(identifier) ?? null;
  }

  /* -------------------------------------------- */

  /**
   * Register a spell list journal entry page.
   * @param {string} uuid  UUID of a spell list journal entry page.
   */
  static async register(uuid) {
    RegistryStatus.set("spellLists", false);
    this.#loading.add(uuid);
    if ( !game.ready ) {
      Hooks.once("ready", () => this.register(uuid));
      return;
    }

    const page = await fromUuid(uuid);
    if ( !page ) throw new Error(`Journal entry page "${uuid}" could not be found to register as spell list.`);
    if ( page.type !== "spells" ) throw new Error(`Journal entry page "${uuid}" is not a Spell List.`);

    if ( !SpellListRegistry.#byType.has(page.system.type) ) SpellListRegistry.#byType.set(page.system.type, new Map());

    const type = SpellListRegistry.#byType.get(page.system.type);
    if ( !type.has(page.system.identifier) ) type.set(page.system.identifier, new SpellList({
      identifier: page.system.identifier, name: page.name, type: page.system.type
    }));

    const list = type.get(page.system.identifier);
    list.contribute(page).forEach(uuid => {
      if ( !SpellListRegistry.#bySpell.has(uuid) ) SpellListRegistry.#bySpell.set(uuid, new Set());
      SpellListRegistry.#bySpell.get(uuid).add(list);
    });

    this.#loading.delete(uuid);
    if ( this.ready ) RegistryStatus.set("spellLists", true);
  }
}

/**
 * Type that represents a unified spell list for a specific class, subclass, species, or something else.
 */
class SpellList {
  constructor(metadata) {
    this.#metadata = Object.freeze(metadata);
  }

  /* -------------------------------------------- */

  /**
   * Mapping of spell list types to item registries.
   * @enum {string}
   */
  static #REGISTRIES = {
    class: "classes"
  };

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * Indexes for the available spells sorted by name.
   * @returns {object[]}
   */
  get indexes() {
    return Array.from(this.#spells.keys())
      .map(s => fromUuidSync(s))
      .sort((lhs, rhs) => lhs.name.localeCompare(rhs.name, game.i18n.lang));
  }

  /* -------------------------------------------- */

  /**
   * Information on the spell list.
   * @type {{ identifier: string, name: string, type: string }}
   */
  #metadata;

  get metadata() {
    return this.#metadata;
  }

  /* -------------------------------------------- */

  /**
   * Display name for the spell list.
   * @type {string}
   */
  get name() {
    return dnd5e.registry[SpellList.#REGISTRIES[this.metadata.type]]?.get(this.metadata.identifier)?.name
      ?? this.metadata.name;
  }

  /* -------------------------------------------- */

  /**
   * @typedef {SpellData}
   * @property {string} page  UUID of the original page.
   */

  /**
   * Spells represented by this spell list.
   * @type {Map<string, SpellList.SpellData>}
   */
  #spells = new Map();

  /* -------------------------------------------- */

  /**
   * Unlinked spell definitions.
   * @type {UnlinkedSpellConfiguration[]}
   */
  #unlinked = [];

  /* -------------------------------------------- */

  /**
   * UUIDs of all of the spells in this list.
   * @type {Set<string>}
   */
  get uuids() {
    return new Set(this.#spells.keys());
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Add a spell list page to this unified spell list.
   * @param {JournalEntryPage} page  Spells page to contribute.
   * @returns {Set<string>}          Newly added UUIDs.
   */
  contribute(page) {
    const added = new Set();

    page.system.spells.forEach(s => {
      if ( !this.#spells.has(s) ) added.add(s);
      this.#spells.set(s, { page: page.uuid });
    });

    for ( const unlinked of page.system.unlinkedSpells ) {
      if ( fromUuidSync(unlinked.source?.uuid) ) {
        if ( !this.#spells.has(unlinked.source.uuid) ) added.add(unlinked.source.uuid);
        this.#spells.set(unlinked.source.uuid, { page: page.uuid });
      } else {
        this.#unlinked.push(foundry.utils.mergeObject({ page: page.uuid }, unlinked));
      }
    }

    return added;
  }

  /* -------------------------------------------- */

  /**
   * All of the spells represented by this list.
   * @returns {Promise<Item5e>}
   */
  async getSpells() {
    return Promise.all(Array.from(this.#spells.keys()).map(s => fromUuid(s)));
  }
}

/* -------------------------------------------- */
/*  Summons                                     */
/* -------------------------------------------- */

class SummonRegistry {
  /**
   * Registration of summoned creatures mapped to a specific summoner. The map is keyed by the UUID of
   * summoner while the set contains UUID of actors that have been summoned.
   * @type {Map<string, Set<string>>}
   */
  static #creatures = new Map();

  /* -------------------------------------------- */

  /**
   * Fetch creatures summoned by an actor.
   * @param {Actor5e} actor  Actor for which to find the summoned creatures.
   * @returns {Actor5e[]}
   */
  static creatures(actor) {
    return Array.from(SummonRegistry.#creatures.get(actor.uuid) ?? []).map(uuid => fromUuidSync(uuid));
  }

  /* -------------------------------------------- */

  /**
   * Add a new summoned creature to the list of summoned creatures.
   * @param {string} summoner  UUID of the actor who performed the summoning.
   * @param {string} summoned  UUID of the summoned creature to track.
   */
  static track(summoner, summoned) {
    if ( summoned.startsWith("Compendium.") ) return;
    if ( !SummonRegistry.#creatures.has(summoner) ) {
      SummonRegistry.#creatures.set(summoner, new Set());
    }
    SummonRegistry.#creatures.get(summoner).add(summoned);
  }

  /* -------------------------------------------- */

  /**
   * Stop tracking a summoned creature.
   * @param {string} summoner  UUID of the actor who performed the summoning.
   * @param {string} summoned  UUID of the summoned creature to stop tracking.
   */
  static untrack(summoner, summoned) {
    SummonRegistry.#creatures.get(summoner)?.delete(summoned);
  }
}

/* -------------------------------------------- */
/*  Ready API                                   */
/* -------------------------------------------- */

/**
 * Track the ready status of various registries.
 * @type {Map<string, boolean>}
 */
const RegistryStatus = new class extends Map {
  constructor(iterable) {
    super(iterable);
    const { promise, resolve } = Promise.withResolvers();
    this.#ready = promise;
    this.#resolve = resolve;
  }

  /* -------------------------------------------- */

  /**
   * Promise that resolves when the registry is ready.
   * @type {Promise}
   */
  #ready;

  /* -------------------------------------------- */

  /**
   * Promise that resolves when all registries are ready.
   * @returns {Promise}
   */
  get ready() {
    return this.#ready;
  }

  /* -------------------------------------------- */

  /**
   * Internal method called when registry is ready.
   * @type {Function}
   */
  #resolve;

  /* -------------------------------------------- */

  /** @inheritDoc */
  set(key, value) {
    super.set(key, value);
    if ( Array.from(this.values()).every(s => s) ) this.#resolve();
    return this;
  }
}();

/* -------------------------------------------- */

var registry = {
  classes: new ItemRegistry("class"),
  enchantments: EnchantmentRegisty,
  messages: MessageRegistry,
  ready: RegistryStatus.ready,
  spellLists: SpellListRegistry,
  summons: SummonRegistry
};

/**
 * A class responsible for orchestrating tooltips in the system.
 */
class Tooltips5e {
  /* -------------------------------------------- */
  /*  Properties & Getters                        */
  /* -------------------------------------------- */

  /**
   * The currently registered observer.
   * @type {MutationObserver}
   */
  #observer;

  /**
   * The tooltip element.
   * @type {HTMLElement}
   */
  get tooltip() {
    return document.getElementById("tooltip");
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /**
   * Initialize the mutation observer.
   */
  observe() {
    this.#observer?.disconnect();
    this.#observer = new MutationObserver(this._onMutation.bind(this));
    this.#observer.observe(this.tooltip, { attributeFilter: ["class"], attributeOldValue: true });
  }

  /* -------------------------------------------- */

  /**
   * Handle a mutation event.
   * @param {MutationRecord[]} mutationList  The list of changes.
   * @protected
   */
  _onMutation(mutationList) {
    let isActive = false;
    const tooltip = this.tooltip;
    for ( const { type, attributeName, oldValue } of mutationList ) {
      if ( (type === "attributes") && (attributeName === "class") ) {
        const difference = new Set(tooltip.classList).difference(new Set(oldValue?.split(" ")));
        if ( difference.has("active") ) isActive = true;
      }
    }
    if ( isActive ) this._onTooltipActivate();
  }

  /* -------------------------------------------- */

  /**
   * Handle tooltip activation.
   * @protected
   * @returns {Promise}
   */
  async _onTooltipActivate() {
    // General content links
    if ( game.tooltip.element?.classList.contains("content-link") ) {
      const doc = await fromUuid(game.tooltip.element.dataset.uuid);
      return this._onHoverContentLink(doc);
    }

    const loading = this.tooltip.querySelector(".loading");

    // Sheet-specific tooltips
    if ( loading?.dataset.uuid ) {
      const doc = await fromUuid(loading.dataset.uuid);
      if ( doc instanceof dnd5e.documents.Actor5e ) return this._onHoverActor(doc);
      return this._onHoverContentLink(doc);
    }

    // Passive checks
    else if ( loading?.dataset.passive !== undefined ) {
      const { skill, ability, dc } = game.tooltip.element?.dataset ?? {};
      return this._onHoverPassive(skill, ability, dc);
    }
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering some part of an actor's sheet.
   * @param {Actor5e} actor  The actor.
   * @protected
   */
  async _onHoverActor(actor) {
    const { attribution, attributionCaption } = game.tooltip.element.dataset;
    if ( !attribution ) return;
    this.tooltip.innerHTML = await actor.getAttributionData(attribution, { title: attributionCaption });
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering over a content link and showing rich tooltips if possible.
   * @param {Document} doc  The document linked by the content link.
   * @protected
   */
  async _onHoverContentLink(doc) {
    const { content, classes } = await (doc.richTooltip?.() ?? doc.system?.richTooltip?.() ?? {});
    if ( !content ) return;
    this.tooltip.innerHTML = content;
    classes?.forEach(c => this.tooltip.classList.add(c));
    const { tooltipDirection } = game.tooltip.element.dataset;
    requestAnimationFrame(() => this._positionItemTooltip(tooltipDirection));
  }

  /* -------------------------------------------- */

  /**
   * Handle hovering a passive skill or ability check link to display results for primary party.
   * Either skill or ability (or both) must be provided.
   * @param {string} [skill]     Passive skill key. If undefined, this will be a passive ability check.
   * @param {string} [ability]   Passive ability key. If undefined, the skill's default ability is used.
   * @param {number} [dc]        DC against which to compare party values.
   * @protected
   */
  async _onHoverPassive(skill, ability, dc) {
    const skillConfig = CONFIG.DND5E.skills[skill];
    const abilityConfig = CONFIG.DND5E.abilities[ability ?? skillConfig.ability];

    let label;
    if ( skillConfig ) {
      label = game.i18n.format("DND5E.SkillPassiveSpecificHint", { skill: skillConfig.label, ability: abilityConfig.label });
    } else {
      // If no skill was provided, we're doing a passive ability check.
      // This isn't technically a thing in the rules, but we can support it anyway if people want to use it.
      label = game.i18n.format("DND5E.SkillPassiveHint", { skill: abilityConfig.label });
    }

    const party = game.settings.get("dnd5e", "primaryParty")?.actor;
    if ( !party ) {
      this.tooltip.innerHTML = label;
      return;
    }

    const context = { label, party: [] };
    for ( const member of party.system.members ) {
      const systemData = member.actor?.system;
      let passive;
      if ( skill && (!ability || (ability === skillConfig.ability)) ) {
        // Default passive skill check
        passive = systemData?.skills?.[skill]?.passive;
      } else if ( skill ) {
        // Passive ability check with custom ability
        const customSkillData = member.actor?._prepareSkill(skill, { ability });
        passive = customSkillData.passive;
      } else {
        // Passive ability check
        const abilityMod = systemData?.abilities?.[ability]?.mod;
        if ( abilityMod !== undefined ) passive = 10 + abilityMod;
      }

      if ( !passive ) continue;
      const data = { name: member.actor.name, img: member.actor.img, passive };
      if ( dc !== undefined ) data.status = passive >= dc ? "success" : "failure";
      context.party.push(data);
    }

    this.tooltip.classList.add("dnd5e-tooltip", "passive-tooltip");
    this.tooltip.innerHTML = await renderTemplate("systems/dnd5e/templates/journal/passive-tooltip.hbs", context);
    game.tooltip._setAnchor(TooltipManager.TOOLTIP_DIRECTIONS.DOWN);
  }

  /* -------------------------------------------- */

  /**
   * Position a tooltip after rendering.
   * @param {string} [direction]  The direction to position the tooltip.
   * @protected
   */
  _positionItemTooltip(direction) {
    if ( !direction ) {
      direction = TooltipManager.TOOLTIP_DIRECTIONS.LEFT;
      game.tooltip._setAnchor(direction);
    }

    const pos = this.tooltip.getBoundingClientRect();
    const dirs = TooltipManager.TOOLTIP_DIRECTIONS;
    switch ( direction ) {
      case dirs.UP:
        if ( pos.y - TooltipManager.TOOLTIP_MARGIN_PX <= 0 ) direction = dirs.DOWN;
        break;
      case dirs.DOWN:
        if ( pos.y + this.tooltip.offsetHeight > window.innerHeight ) direction = dirs.UP;
        break;
      case dirs.LEFT:
        if ( pos.x - TooltipManager.TOOLTIP_MARGIN_PX <= 0 ) direction = dirs.RIGHT;
        break;
      case dirs.RIGHT:
        if ( pos.x + this.tooltip.offsetWidth > window.innerWith ) direction = dirs.LEFT;
        break;
    }

    game.tooltip._setAnchor(direction);

    // Set overflowing styles for item tooltips.
    if ( tooltip.classList.contains("item-tooltip") ) {
      const description = tooltip.querySelector(".description");
      description?.classList.toggle("overflowing", description.clientHeight < description.scrollHeight);
    }
  }

  /* -------------------------------------------- */
  /*  Static Helpers                              */
  /* -------------------------------------------- */

  /**
   * Intercept middle-click listeners to prevent scrolling behavior inside a locked tooltip when attempting to lock
   * another tooltip.
   */
  static activateListeners() {
    document.addEventListener("pointerdown", event => {
      if ( (event.button === 1) && event.target.closest(".locked-tooltip") ) {
        event.preventDefault();
      }
    }, { capture: true });
  }
}

/**
 * The D&D fifth edition game system for Foundry Virtual Tabletop
 * A system for playing the fifth edition of the world's most popular role-playing game.
 * Author: Atropos
 * Software License: MIT
 * Content License: https://www.dndbeyond.com/attachments/39j2li89/SRD5.1-CCBY4.0License.pdf
 * Repository: https://github.com/foundryvtt/dnd5e
 * Issue Tracker: https://github.com/foundryvtt/dnd5e/issues
 */


/* -------------------------------------------- */
/*  Define Module Structure                     */
/* -------------------------------------------- */

globalThis.dnd5e = {
  applications,
  canvas: canvas$1,
  config: DND5E,
  dataModels,
  dice,
  documents,
  enrichers,
  Filter,
  migrations,
  registry,
  utils
};

DragDrop = DragDrop5e;

/* -------------------------------------------- */
/*  Foundry VTT Initialization                  */
/* -------------------------------------------- */

Hooks.once("init", function() {
  globalThis.dnd5e = game.dnd5e = Object.assign(game.system, globalThis.dnd5e);
  log(`Initializing the D&D Fifth Game System - Version ${dnd5e.version}\n${DND5E.ASCII}`);

  if ( game.release.generation < 13 ) patchFromUuid();
  CONFIG.compatibility.excludePatterns.push(
    /now namespaced under/, /V1 Application framework/, /Set#isSubset/, /ChatMessage#getHTML/, /renderChatMessage/
  );

  // Record Configuration Values
  CONFIG.DND5E = DND5E;
  CONFIG.ActiveEffect.documentClass = ActiveEffect5e;
  CONFIG.ActiveEffect.legacyTransferral = false;
  CONFIG.Actor.documentClass = Actor5e;
  CONFIG.ChatMessage.documentClass = ChatMessage5e;
  CONFIG.Combat.documentClass = Combat5e;
  CONFIG.Combatant.documentClass = Combatant5e;
  CONFIG.Item.collection = Items5e;
  CONFIG.Item.compendiumIndexFields.push("system.container");
  CONFIG.Item.documentClass = Item5e;
  CONFIG.JournalEntryPage.documentClass = JournalEntryPage5e;
  CONFIG.Token.documentClass = TokenDocument5e;
  CONFIG.Token.objectClass = Token5e;
  CONFIG.User.documentClass = User5e;
  CONFIG.time.roundTime = 6;
  Roll.TOOLTIP_TEMPLATE = "systems/dnd5e/templates/chat/roll-breakdown.hbs";
  CONFIG.Dice.BasicRoll = BasicRoll;
  CONFIG.Dice.DamageRoll = DamageRoll;
  CONFIG.Dice.D20Die = D20Die;
  CONFIG.Dice.D20Roll = D20Roll;
  CONFIG.MeasuredTemplate.defaults.angle = 53.13; // 5e cone RAW should be 53.13 degrees
  CONFIG.Note.objectClass = Note5e;
  CONFIG.ui.chat = ChatLog5e;
  CONFIG.ui.combat = CombatTracker5e;
  CONFIG.ui.items = ItemDirectory5e;

  // Register System Settings
  registerSystemSettings();
  registerSystemKeybindings();

  // Configure module art & register module data
  game.dnd5e.moduleArt = new ModuleArt();
  registerModuleData();

  // Configure bastions
  game.dnd5e.bastion = new Bastion();

  // Configure tooltips
  game.dnd5e.tooltips = new Tooltips5e();

  // Set up status effects
  _configureStatusEffects();

  // Remove honor & sanity from configuration if they aren't enabled
  if ( !game.settings.get("dnd5e", "honorScore") ) delete DND5E.abilities.hon;
  if ( !game.settings.get("dnd5e", "sanityScore") ) delete DND5E.abilities.san;

  // Legacy rules.
  if ( game.settings.get("dnd5e", "rulesVersion") === "legacy" ) {

    // Set half-casters to round down.
    delete DND5E.spellcastingTypes.leveled.progression.half.roundUp;

    // Adjust Wild Shape and Polymorph presets.
    delete DND5E.transformationPresets.wildshape.options.keepHP;
    delete DND5E.transformationPresets.wildshape.options.keepType;
    delete DND5E.transformationPresets.polymorph.options.addTemp;
    delete DND5E.transformationPresets.polymorph.options.keepHP;
    delete DND5E.transformationPresets.polymorph.options.keepType;

    // Adjust language categories.
    delete DND5E.languages.standard.children.sign;
    DND5E.languages.exotic.children.draconic = DND5E.languages.standard.children.draconic;
    delete DND5E.languages.standard.children.draconic;
    DND5E.languages.cant = DND5E.languages.exotic.children.cant;
    delete DND5E.languages.exotic.children.cant;
    DND5E.languages.druidic = DND5E.languages.exotic.children.druidic;
    delete DND5E.languages.exotic.children.druidic;

    // Stunned stops movement in legacy.
    DND5E.conditionEffects.noMovement.add("stunned");
  }

  // Register Roll Extensions
  CONFIG.Dice.rolls = [BasicRoll, D20Roll, DamageRoll];

  // Hook up system data types
  CONFIG.ActiveEffect.dataModels = config$4;
  CONFIG.Actor.dataModels = config$3;
  CONFIG.ChatMessage.dataModels = config$2;
  CONFIG.Item.dataModels = config$1;
  CONFIG.JournalEntryPage.dataModels = config;

  // Add fonts
  _configureFonts();

  // Register sheet application classes
  Actors.unregisterSheet("core", ActorSheet);
  Actors.registerSheet("dnd5e", ActorSheet5eCharacter, {
    types: ["character"],
    label: "DND5E.SheetClassCharacterLegacy"
  });
  DocumentSheetConfig.registerSheet(Actor, "dnd5e", ActorSheet5eCharacter2, {
    types: ["character"],
    makeDefault: true,
    label: "DND5E.SheetClassCharacter"
  });
  Actors.registerSheet("dnd5e", ActorSheet5eNPC, {
    types: ["npc"],
    label: "DND5E.SheetClassNPCLegacy"
  });
  DocumentSheetConfig.registerSheet(Actor, "dnd5e", ActorSheet5eNPC2, {
    types: ["npc"],
    makeDefault: true,
    label: "DND5E.SheetClassNPC"
  });
  Actors.registerSheet("dnd5e", ActorSheet5eVehicle, {
    types: ["vehicle"],
    makeDefault: true,
    label: "DND5E.SheetClassVehicle"
  });
  Actors.registerSheet("dnd5e", GroupActorSheet, {
    types: ["group"],
    makeDefault: true,
    label: "DND5E.SheetClassGroup"
  });

  DocumentSheetConfig.unregisterSheet(Item, "core", ItemSheet);
  DocumentSheetConfig.registerSheet(Item, "dnd5e", ItemSheet5e2, {
    makeDefault: true,
    label: "DND5E.SheetClassItem"
  });
  DocumentSheetConfig.unregisterSheet(Item, "dnd5e", ItemSheet5e, { types: ["container"] });
  DocumentSheetConfig.unregisterSheet(Item, "dnd5e", ItemSheet5e2, { types: ["container"] });
  DocumentSheetConfig.registerSheet(Item, "dnd5e", ContainerSheet2, {
    makeDefault: true,
    types: ["container"],
    label: "DND5E.SheetClassContainer"
  });

  DocumentSheetConfig.registerSheet(JournalEntry, "dnd5e", JournalSheet5e, {
    makeDefault: true,
    label: "DND5E.SheetClassJournalEntry"
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalClassPageSheet, {
    label: "DND5E.SheetClassClassSummary",
    types: ["class", "subclass"]
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalMapLocationPageSheet, {
    label: "DND5E.SheetClassMapLocation",
    types: ["map"]
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalRulePageSheet, {
    label: "DND5E.SheetClassRule",
    types: ["rule"]
  });
  DocumentSheetConfig.registerSheet(JournalEntryPage, "dnd5e", JournalSpellListPageSheet, {
    label: "DND5E.SheetClassSpellList",
    types: ["spells"]
  });

  CONFIG.Token.prototypeSheetClass = game.release.generation < 13
    ? TokenConfig5e
    : PrototypeTokenConfig5e;
  DocumentSheetConfig.unregisterSheet(TokenDocument, "core", TokenConfig);
  DocumentSheetConfig.registerSheet(TokenDocument, "dnd5e", TokenConfig5e, {
    label: "DND5E.SheetClassToken"
  });

  // Preload Handlebars helpers & partials
  registerHandlebarsHelpers();
  preloadHandlebarsTemplates();

  // Enrichers
  registerCustomEnrichers();

  // Exhaustion handling
  ActiveEffect5e.registerHUDListeners();
});

/* -------------------------------------------- */

/**
 * Configure explicit lists of attributes that are trackable on the token HUD and in the combat tracker.
 * @internal
 */
function _configureTrackableAttributes() {
  const common = {
    bar: [],
    value: [
      ...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`),
      ...Object.keys(DND5E.movementTypes).map(movement => `attributes.movement.${movement}`),
      "attributes.ac.value", "attributes.init.total"
    ]
  };

  const altSpells = Object.entries(DND5E.spellPreparationModes).reduce((acc, [k, v]) => {
    if ( !["prepared", "always"].includes(k) && v.upcast ) acc.push(`spells.${k}`);
    return acc;
  }, []);

  const creature = {
    bar: [
      ...common.bar,
      "attributes.hp",
      ...altSpells,
      ...Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).map(l => `spells.spell${l}`)
    ],
    value: [
      ...common.value,
      ...Object.keys(DND5E.skills).map(skill => `skills.${skill}.passive`),
      ...Object.keys(DND5E.senses).map(sense => `attributes.senses.${sense}`),
      "attributes.spell.attack", "attributes.spell.dc"
    ]
  };

  CONFIG.Actor.trackableAttributes = {
    character: {
      bar: [...creature.bar, "resources.primary", "resources.secondary", "resources.tertiary", "details.xp"],
      value: [...creature.value]
    },
    npc: {
      bar: [...creature.bar, "resources.legact", "resources.legres"],
      value: [...creature.value, "attributes.spell.level", "details.cr", "details.xp.value"]
    },
    vehicle: {
      bar: [...common.bar, "attributes.hp"],
      value: [...common.value]
    },
    group: {
      bar: [],
      value: []
    }
  };
}

/* -------------------------------------------- */

/**
 * Configure which attributes are available for item consumption.
 * @internal
 */
function _configureConsumableAttributes() {
  const altSpells = Object.entries(DND5E.spellPreparationModes).reduce((acc, [k, v]) => {
    if ( !["prepared", "always"].includes(k) && v.upcast ) acc.push(`spells.${k}.value`);
    return acc;
  }, []);

  CONFIG.DND5E.consumableResources = [
    ...Object.keys(DND5E.abilities).map(ability => `abilities.${ability}.value`),
    "attributes.ac.flat",
    "attributes.hp.value",
    "attributes.exhaustion",
    ...Object.keys(DND5E.senses).map(sense => `attributes.senses.${sense}`),
    ...Object.keys(DND5E.movementTypes).map(type => `attributes.movement.${type}`),
    ...Object.keys(DND5E.currencies).map(denom => `currency.${denom}`),
    "details.xp.value",
    "resources.primary.value", "resources.secondary.value", "resources.tertiary.value",
    "resources.legact.value", "resources.legres.value",
    ...altSpells,
    ...Array.fromRange(Object.keys(DND5E.spellLevels).length - 1, 1).map(level => `spells.spell${level}.value`)
  ];
}

/* -------------------------------------------- */

/**
 * Configure additional system fonts.
 */
function _configureFonts() {
  Object.assign(CONFIG.fontDefinitions, {
    Roboto: {
      editor: true,
      fonts: [
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-Regular.woff2"] },
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-Bold.woff2"], weight: "bold" },
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-Italic.woff2"], style: "italic" },
        { urls: ["systems/dnd5e/fonts/roboto/Roboto-BoldItalic.woff2"], weight: "bold", style: "italic" }
      ]
    },
    "Roboto Condensed": {
      editor: true,
      fonts: [
        { urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-Regular.woff2"] },
        { urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-Bold.woff2"], weight: "bold" },
        { urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-Italic.woff2"], style: "italic" },
        {
          urls: ["systems/dnd5e/fonts/roboto-condensed/RobotoCondensed-BoldItalic.woff2"], weight: "bold",
          style: "italic"
        }
      ]
    },
    "Roboto Slab": {
      editor: true,
      fonts: [
        { urls: ["systems/dnd5e/fonts/roboto-slab/RobotoSlab-Regular.ttf"] },
        { urls: ["systems/dnd5e/fonts/roboto-slab/RobotoSlab-Bold.ttf"], weight: "bold" }
      ]
    }
  });
}

/* -------------------------------------------- */

/**
 * Configure system status effects.
 */
function _configureStatusEffects() {
  const addEffect = (effects, {special, ...data}) => {
    data = foundry.utils.deepClone(data);
    data._id = staticID(`dnd5e${data.id}`);
    data.img = data.icon ?? data.img;
    delete data.icon;
    effects.push(data);
    if ( special ) CONFIG.specialStatusEffects[special] = data.id;
  };
  CONFIG.statusEffects = Object.entries(CONFIG.DND5E.statusEffects).reduce((arr, [id, data]) => {
    const original = CONFIG.statusEffects.find(s => s.id === id);
    addEffect(arr, foundry.utils.mergeObject(original ?? {}, { id, ...data }, { inplace: false }));
    return arr;
  }, []);
  for ( const [id, {label: name, ...data}] of Object.entries(CONFIG.DND5E.conditionTypes) ) {
    addEffect(CONFIG.statusEffects, { id, name, ...data });
  }
  for ( const [id, data] of Object.entries(CONFIG.DND5E.encumbrance.effects) ) {
    addEffect(CONFIG.statusEffects, { id, ...data, hud: false });
  }
}

/* -------------------------------------------- */
/*  Foundry VTT Setup                           */
/* -------------------------------------------- */

/**
 * Prepare attribute lists.
 */
Hooks.once("setup", function() {
  // Configure trackable & consumable attributes.
  _configureTrackableAttributes();
  _configureConsumableAttributes();

  CONFIG.DND5E.trackableAttributes = expandAttributeList(CONFIG.DND5E.trackableAttributes);
  game.dnd5e.moduleArt.registerModuleArt();
  Tooltips5e.activateListeners();
  game.dnd5e.tooltips.observe();

  // Register settings after modules have had a chance to initialize
  registerDeferredSettings();

  // Set up compendiums with custom applications & sorting
  setupModulePacks();

  // Create CSS for currencies
  const style = document.createElement("style");
  const currencies = append => Object.entries(CONFIG.DND5E.currencies)
    .map(([key, { icon }]) => `&.${key}${append ?? ""} { background-image: url("${icon}"); }`);
  style.innerHTML = `
    :is(.dnd5e2, .dnd5e2-journal) :is(i, span).currency {
      ${currencies().join("\n")}
    }
    .dnd5e2 .form-group label.label-icon.currency {
      ${currencies("::after").join("\n")}
    }
  `;
  document.head.append(style);
});

/* --------------------------------------------- */

/**
 * Expand a list of attribute paths into an object that can be traversed.
 * @param {string[]} attributes  The initial attributes configuration.
 * @returns {object}  The expanded object structure.
 */
function expandAttributeList(attributes) {
  return attributes.reduce((obj, attr) => {
    foundry.utils.setProperty(obj, attr, true);
    return obj;
  }, {});
}

/* --------------------------------------------- */

/**
 * Perform one-time pre-localization and sorting of some configuration objects
 */
Hooks.once("i18nInit", () => {
  if ( game.settings.get("dnd5e", "rulesVersion") === "legacy" ) {
    const { translations, _fallback } = game.i18n;
    foundry.utils.mergeObject(translations, {
      "TYPES.Item": {
        race: game.i18n.localize("TYPES.Item.raceLegacy"),
        racePl: game.i18n.localize("TYPES.Item.raceLegacyPl")
      },
      DND5E: {
        "Feature.Species": game.i18n.localize("DND5E.Feature.SpeciesLegacy"),
        FlagsAlertHint: game.i18n.localize("DND5E.FlagsAlertHintLegacy"),
        ItemSpeciesDetails: game.i18n.localize("DND5E.ItemSpeciesDetailsLegacy"),
        "Language.Category.Rare": game.i18n.localize("DND5E.Language.Category.Exotic"),
        RacialTraits: game.i18n.localize("DND5E.RacialTraitsLegacy"),
        "REST.Long.Hint.Normal": game.i18n.localize("DND5E.REST.Long.Hint.NormalLegacy"),
        "REST.Long.Hint.Group": game.i18n.localize("DND5E.REST.Long.Hint.GroupLegacy"),
        "Species.Add": game.i18n.localize("DND5E.Species.AddLegacy"),
        "Species.Features": game.i18n.localize("DND5E.Species.FeaturesLegacy"),
        "TARGET.Type.Emanation": foundry.utils.mergeObject(
          _fallback.DND5E?.TARGET?.Type?.Radius ?? {},
          translations.DND5E?.TARGET?.Type?.Radius ?? {},
          { inplace: false }
        ),
        TraitArmorPlural: foundry.utils.mergeObject(
          _fallback.DND5E?.TraitArmorLegacyPlural ?? {},
          translations.DND5E?.TraitArmorLegacyPlural ?? {},
          { inplace: false }
        ),
        TraitArmorProf: game.i18n.localize("DND5E.TraitArmorLegacyProf")
      }
    });
  }
  performPreLocalization(CONFIG.DND5E);
  Object.values(CONFIG.DND5E.activityTypes).forEach(c => c.documentClass.localize());
  Object.values(CONFIG.DND5E.advancementTypes).forEach(c => c.documentClass.localize());
});

/* -------------------------------------------- */
/*  Foundry VTT Ready                           */
/* -------------------------------------------- */

/**
 * Once the entire VTT framework is initialized, check to see if we should perform a data migration
 */
Hooks.once("ready", function() {
  // Wait to register hotbar drop hook on ready so that modules could register earlier if they want to
  Hooks.on("hotbarDrop", (bar, data, slot) => {
    if ( ["ActiveEffect", "Activity", "Item"].includes(data.type) ) {
      create5eMacro(data, slot);
      return false;
    }
  });

  // Adjust sourced items on actors now that compendium UUID redirects have been initialized
  game.actors.forEach(a => a.sourcedItems._redirectKeys());

  // Register items by type
  dnd5e.registry.classes.initialize();

  // Chat message listeners
  ChatMessage5e.activateListeners();

  // Bastion initialization
  game.dnd5e.bastion.initializeUI();

  // Determine whether a system migration is required and feasible
  if ( !game.user.isGM ) return;
  const cv = game.settings.get("dnd5e", "systemMigrationVersion") || game.world.flags.dnd5e?.version;
  const totalDocuments = game.actors.size + game.scenes.size + game.items.size;
  if ( !cv && totalDocuments === 0 ) return game.settings.set("dnd5e", "systemMigrationVersion", game.system.version);
  if ( cv && !foundry.utils.isNewerVersion(game.system.flags.needsMigrationVersion, cv) ) return;

  // Compendium pack folder migration.
  if ( foundry.utils.isNewerVersion("3.0.0", cv) ) {
    reparentCompendiums("DnD5e SRD Content", "D&D SRD Content");
  }

  // Perform the migration
  if ( cv && foundry.utils.isNewerVersion(game.system.flags.compatibleMigrationVersion, cv) ) {
    ui.notifications.error("MIGRATION.5eVersionTooOldWarning", {localize: true, permanent: true});
  }
  migrateWorld();
});

/* -------------------------------------------- */
/*  System Styling                              */
/* -------------------------------------------- */

Hooks.on("renderPause", (app, [html]) => {
  html.classList.add("dnd5e2");
  const img = html.querySelector("img");
  img.src = "systems/dnd5e/ui/official/ampersand.svg";
  img.className = "";
});

Hooks.on("renderSettings", (app, html) => {
  html = html instanceof HTMLElement ? html : html[0];
  if ( game.release.generation > 12 ) renderSettings(html);
  else renderSettingsLegacy(html);
});

/* -------------------------------------------- */
/*  Other Hooks                                 */
/* -------------------------------------------- */

Hooks.on("renderChatPopout", ChatMessage5e.onRenderChatPopout);
Hooks.on("getChatLogEntryContext", ChatMessage5e.addChatMessageContextOptions);

Hooks.on("renderChatLog", (app, html, data) => {
  Item5e.chatListeners(html);
  ChatMessage5e.onRenderChatLog(html);
});
Hooks.on("renderChatPopout", (app, html, data) => Item5e.chatListeners(html));

Hooks.on("chatMessage", (app, message, data) => Award.chatMessage(message));

Hooks.on("renderActorDirectory", (app, html, data) => Actor5e.onRenderActorDirectory(html));
Hooks.on("getActorDirectoryEntryContext", Actor5e.addDirectoryContextOptions);

Hooks.on("renderCompendiumDirectory", (app, html) => {
  html = html instanceof HTMLElement ? html : html[0];
  CompendiumBrowser.injectSidebarButton(html);
});
Hooks.on("getCompendiumEntryContext", Item5e.addCompendiumContextOptions);
Hooks.on("getItemDirectoryEntryContext", Item5e.addDirectoryContextOptions);

Hooks.on("renderJournalPageSheet", JournalSheet5e.onRenderJournalPageSheet);

Hooks.on("renderActiveEffectConfig", ActiveEffect5e.onRenderActiveEffectConfig);

Hooks.on("targetToken", Token5e.onTargetToken);

Hooks.on("renderCombatTracker", (app, html, data) => app.renderGroups(html instanceof HTMLElement ? html : html[0]));

Hooks.on("preCreateScene", (doc, createData, options, userId) => {
  // Set default grid units based on metric length setting
  const units = defaultUnits("length");
  if ( (units !== dnd5e.grid.units) && !foundry.utils.getProperty(createData, "grid.distance")
    && !foundry.utils.getProperty(createData, "grid.units") ) {
    const C = CONFIG.DND5E.movementUnits;
    doc.updateSource({
      grid: {
        // TODO: Replace with `convertLength` method once added
        distance: dnd5e.grid.distance * (C[dnd5e.grid.units]?.conversion ?? 1) / (C[units]?.conversion ?? 1), units
      }
    });
  }
});

// TODO: Generalize this logic and make it available in the re-designed transform application.
Hooks.on("dnd5e.transformActor", (subject, target, d, options) => {
  const isLegacy = game.settings.get("dnd5e", "rulesVersion") === "legacy";
  if ( (options.preset !== "wildshape") || !subject.classes?.druid || isLegacy ) return;
  let temp = subject.classes.druid.system.levels;
  if ( subject.classes.druid.subclass?.identifier === "moon" ) temp *= 3;
  d.system.attributes.hp.temp = temp;
});

/* -------------------------------------------- */
/*  Backported Fixes                            */
/* -------------------------------------------- */

/**
 * FIXME: Remove when v12 support dropped or https://github.com/foundryvtt/foundryvtt/issues/12023 backported.
 * @ignore
 */
function patchFromUuid() {
  const _resolveEmbedded = function(parent, parts, {invalid=false}={}) {
    let doc = parent;
    while ( doc && (parts.length > 1) ) {
      const [embeddedName, embeddedId] = parts.splice(0, 2);
      doc = doc.getEmbeddedDocument(embeddedName, embeddedId, {invalid});
    }
    return doc;
  };

  // Patch fromUuid to call our wrapped parseUuid in order to correctly resolve relative UUIDs on grandchild embedded
  // Documents.
  window.fromUuid = async function(uuid, options={}) {
    if ( !uuid ) return null;
    /** @deprecated since v11 */
    if ( foundry.utils.getType(options) !== "Object" ) {
      foundry.utils.logCompatibilityWarning("Passing a relative document as the second parameter to fromUuid is "
        + "deprecated. Please pass it within an options object instead.", {since: 11, until: 13});
      options = {relative: options};
    }
    const {relative, invalid=false} = options;
    let {type, id, primaryId, collection, embedded, doc} = parseUuid(uuid, {relative});
    if ( collection instanceof CompendiumCollection ) {
      if ( type === "Folder" ) return collection.folders.get(id);
      doc = await collection.getDocument(primaryId ?? id);
    }
    else doc = doc ?? collection?.get(primaryId ?? id, {invalid});
    if ( embedded.length ) doc = _resolveEmbedded(doc, embedded, {invalid});
    return doc || null;
  };
}

export { DND5E, Filter, applications, canvas$1 as canvas, dataModels, dice, documents, enrichers, migrations, registry, utils };
//# sourceMappingURL=dnd5e-compiled.mjs.map
