import BaseActiveEffect from "@common/documents/active-effect.mjs";
import ClientDocumentMixin from "./abstract/client-document.mjs";
import Actor from "./actor.mjs";
import Item from "./item.mjs";
import Hooks from "../helpers/hooks.mjs";
import ActiveEffectRegistry from "@client/helpers/active-effect-registry.mjs";
import Roll from "@client/dice/roll.mjs";
import {ACTIVE_EFFECT_EXPIRY_EVENTS as CORE_EXPIRY_EVENTS} from "@common/constants.mjs";
import {iterateEntries} from "@client/utils/_module.mjs";

/**
 * @import {ActiveEffectChangeData, ActiveEffectChangeHandler, ActiveEffectData,
 *   ActiveEffectDuration, EffectDurationData} from "./_types.mjs";
 * @import {DocumentConstructionContext} from "@common/abstract/_types.mjs";
 * @import Document from "@common/abstract/document.mjs";
 * @import {DataField} from "@common/data/fields.mjs";
 * @import {ActiveEffectChangeTypeConfig} from "@client/config.mjs";
 * @import {Combat, Combatant, TokenDocument} from "./_module.mjs";
 */

/**
 * The client-side ActiveEffect document which extends the common BaseActiveEffect model.
 * Each ActiveEffect belongs to the effects collection of its parent Document.
 * Each ActiveEffect contains a ActiveEffectData object which provides its source data.
 *
 * ### Hook Events
 * - {@link hookEvents.applyActiveEffect}
 *
 * @extends BaseActiveEffect
 * @mixes ClientDocumentMixin
 * @category Documents
 *
 * @see {@link Actor}: The Actor document which contains ActiveEffect embedded documents
 * @see {@link Item}: The Item document which contains ActiveEffect embedded documents
 *
 * @property {ActiveEffectDuration} duration        Expanded effect duration data.
 */
export default class ActiveEffect extends ClientDocumentMixin(BaseActiveEffect) {
  /**
   * A cached compilation of core and registered application phases, along with their labels
   * @type {Record<string, {label: string; hint: string}>}
   */
  static get CHANGE_PHASES() {
    if ( ActiveEffect.#CHANGE_PHASES ) return ActiveEffect.#CHANGE_PHASES;
    return ActiveEffect.#CHANGE_PHASES = [
      ...Object.entries(CONFIG.ActiveEffect.phases).map(([value, {label, hint=""}]) => ({value, label, hint})),
      ...CONST.ACTIVE_EFFECT_CHANGE_PHASES.map(value => ({
        value, label: `EFFECT.CHANGES.PHASES.${value}.label`, hint: `EFFECT.CHANGES.PHASES.${value}.hint`
      }))
    ].reduce((phases, phase) => {
      phases[phase.value] = {label: phase.label, hint: phase.hint};
      return phases;
    }, {});
  }

  /** @type {Record<string, {label: string; hint: string}>} */
  static #CHANGE_PHASES;

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

  /**
   * A cached compilation of core and registered change types, along with their labels and default priorities
   * @type {Record<string, ActiveEffectChangeTypeConfig>}
   */
  static get CHANGE_TYPES() {
    if ( ActiveEffect.#CHANGE_TYPES ) return ActiveEffect.#CHANGE_TYPES;
    return ActiveEffect.#CHANGE_TYPES = [
      ...Object.entries(CONST.ACTIVE_EFFECT_CHANGE_TYPES).map(([value, defaultPriority]) => ({
        value, defaultPriority, label: `EFFECT.CHANGES.TYPES.${value}`, handler: null, render: null
      })),
      ...Object.entries(CONFIG.ActiveEffect.changeTypes).map(([value, config]) => ({
        value,
        label: config.label ?? value,
        defaultPriority: config.defaultPriority ?? 0,
        handler: config.handler ?? null,
        render: config.render ?? null
      }))
    ].reduce((types, type) => {
      types[type.value] = {label: type.label, defaultPriority: type.defaultPriority, handler: type.handler,
        render: type.render};
      return types;
    }, {});
  }

  /** @type {Record<string, ActiveEffectChangeTypeConfig>} */
  static #CHANGE_TYPES;

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

  /**
   * A cached compilation of core and registered expiry events
   * @type {Record<string, string>}
   */
  static get EXPIRY_EVENTS() {
    if ( ActiveEffect.#EXPIRY_EVENTS ) return ActiveEffect.#EXPIRY_EVENTS;
    return ActiveEffect.#EXPIRY_EVENTS = [
      ...Object.entries(CONFIG.ActiveEffect.expiryEvents).map(([value, label]) => ({value, label})),
      ...CORE_EXPIRY_EVENTS.map(value => ({value, label: `EFFECT.DURATION.EXPIRY_EVENTS.${value}`}))
    ].reduce((events, event) => {
      events[event.value] = event.label;
      return events;
    }, {});
  }

  /** @type {Record<string, string>} */
  static #EXPIRY_EVENTS;

  /**
   * A helper class that accepts registration of ActiveEffects and manages their prepared duration and expiry data.
   * @type {ActiveEffectRegistry}
   */
  static registry = new ActiveEffectRegistry();

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

  /**
   * Create an ActiveEffect instance from some status effect ID.
   * Delegates to {@link ActiveEffect._fromStatusEffect} to create the ActiveEffect instance
   * after creating the ActiveEffect data from the status effect data if `CONFIG.statusEffects`.
   * @param {string} statusId                             The status effect ID.
   * @param {DocumentConstructionContext} [options]       Additional options to pass to the ActiveEffect constructor.
   * @returns {Promise<ActiveEffect>}                     The created ActiveEffect instance.
   *
   * @throws {Error} An error if there is no status effect in `CONFIG.statusEffects` with the given status ID and if
   * the status has implicit statuses but doesn't have a static _id.
   */
  static async fromStatusEffect(statusId, options={}) {
    const status = CONFIG.statusEffects[statusId];
    if ( !status ) throw new Error(`Invalid status ID "${statusId}" provided to ActiveEffect#fromStatusEffect`);
    const {id, hud: _hud, ...effectData} = foundry.utils.deepClone(status);
    effectData.name = _loc(effectData.name);
    effectData.statuses = Array.from(new Set([id, ...effectData.statuses ?? []]));
    if ( (effectData.statuses.length > 1) && !status._id ) {
      throw new Error("Status effects with implicit statuses must have a static _id");
    }
    effectData.showIcon ??= CONST.ACTIVE_EFFECT_SHOW_ICON.ALWAYS;
    return ActiveEffect.implementation._fromStatusEffect(statusId, effectData, options);
  }

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

  /**
   * Create an ActiveEffect instance from status effect data.
   * Called by {@link ActiveEffect.fromStatusEffect}.
   * @param {string} statusId                          The status effect ID.
   * @param {ActiveEffectData} effectData              The status effect data.
   * @param {DocumentConstructionContext} [options]    Additional options to pass to the ActiveEffect constructor.
   * @returns {Promise<ActiveEffect>}                  The created ActiveEffect instance.
   * @protected
   */
  static async _fromStatusEffect(statusId, effectData, options) {
    return new this(effectData, options);
  }

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

  /**
   * The Actor in which this ActiveEffect is embedded, either directly or as a grandchild Document
   * @type {Actor|null}
   */
  get actor() {
    if ( !this.isEmbedded ) return null;
    if ( this.parent instanceof Actor ) return this.parent;
    if ( this.parent instanceof Item ) return this.parent.parent;
    return null;
  }

  /**
   * The Item in which this ActiveEffect is embedded
   * @type {Item|null}
   */
  get item() {
    return this.parent instanceof Item ? this.parent : null;
  }

  /**
   * Provide a thumbnail image path used to represent this document.
   * @type {string}
   */
  get thumbnail() {
    return this.img;
  }

  /**
   * Is there some system logic (or, absent that, an expired status) that makes this Active Effect ineligible for
   * application?
   * @type {boolean}
   */
  get isSuppressed() {
    return !!(this.system.isSuppressed ?? this.duration.expired);
  }

  /**
   * Retrieve the Document that this ActiveEffect targets for modification.
   * @type {Document|null}
   */
  get target() {
    if ( !this.isEmbedded ) return null;
    if ( this.parent instanceof Actor ) return this.parent;
    return this.transfer ? (this.parent.parent ?? null) : this.parent;
  }

  /**
   * Whether this Active Effect is currently applying its changes to the target
   * @type {boolean}
   */
  get active() {
    return !this.disabled && !this.isSuppressed;
  }

  /**
   * Whether this Active Effect currently modify an Actor
   * @type {boolean}
   */
  get modifiesActor() {
    return this.isEmbedded && this.active && (this.target?.documentName === "Actor");
  }

  /**
   * Whether this Active Effect has a temporary duration
   * @type {boolean}
   */
  get isTemporary() {
    return !!this.duration.expiry || Number.isFinite(this.duration.value);
  }

  /**
   * Whether this Active Effect is eligible to be registered with the {@link ActiveEffectRegistry}
   */
  get isExpiryTrackable() {
    return this.persisted && !this.inCompendium && this.isEmbedded && this.active && !!this.start && this.isTemporary;
  }

  /**
   * The source name of the Active Effect. The source is retrieved synchronously.
   * Therefore "Unknown" (localized) is returned if the origin points to a document inside a compendium.
   * Returns "None" (localized) if it has no origin, and "Unknown" (localized) if the origin cannot be resolved.
   * @type {string}
   */
  get sourceName() {
    if ( !this.origin ) return _loc("COMMON.None");
    let name;
    try {
      name = foundry.utils.fromUuidSync(this.origin)?.name;
    } catch(e) {}
    return name || _loc("COMMON.Unknown");
  }

  /* -------------------------------------------- */
  /*  Data Preparation                            */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initialize(options={}) {
    super._initialize(options);
    if ( !Object.hasOwn(this, "changes") ) Object.defineProperty(this, "changes", {get: () => this.system.changes});
    this.constructor._shimChanges(this.system.changes);
  }

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

  /** @override */
  prepareBaseData() {
    this.img ??= this.constructor.DEFAULT_ICON;
    this.duration.value ??= Infinity;
    for ( const change of this.system.changes ) {
      change.effect = this;
      change.priority ??= ActiveEffect.CHANGE_TYPES[change.type]?.defaultPriority ?? 0;
    }
  }

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

  /** @override */
  prepareDerivedData() {
    this.updateDuration();
  }

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

  /**
   * Update derived Active Effect duration data.
   * @param {object} [context] Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   */
  updateDuration(context) {
    const unprepared = this.duration.units === this._source.duration.units
      ? this.duration
      : foundry.utils.deepClone(this._source.duration);
    unprepared.value ??= Infinity;
    const duration = this.duration = this._prepareDuration(unprepared, context);
    if ( !Object.hasOwn(duration, "type") || !Object.hasOwn(duration, "duration") ) {
      Object.defineProperties(duration, {
        type: {
          get: () => {
            const message = "You are accessing ActiveEffectDuration#type, which is now at ActiveEffectDuration#units.";
            foundry.utils.logCompatibilityWarning(message, {since: 14, until: 16, once: true});
            return typeof duration.value === "number" ? duration.units : "none";
          },
          configurable: true
        },
        duration: {
          get() {
            foundry.utils.logCompatibilityWarning("You are accessing ActiveEffectDuration#duration, "
              + "which is now at ActiveEffectDuration#seconds.", {since: 14, until: 16, once: true});
            return this.start ? duration.seconds - (game.time.worldtime - this.start.time) : null;
          },
          configurable: true
        }
      });
    }
    return duration;
  }

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

  /**
   * Compute derived data related to active effect duration.
   * @param {EffectDurationData} [duration] Unprepared duration data
   * @param {object} [context]              Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   * @protected
   */
  _prepareDuration(duration, context) {
    duration ??= this.duration;
    const now = game.time.worldTime;
    duration._worldTime = now;

    // Indefinite duration
    if ( !this.isTemporary ) duration.expired = false;
    if ( !Number.isFinite(duration.value) ) {
      return Object.assign(duration, {seconds: Infinity, remaining: Infinity, secondsRemaining: Infinity,
        label: _loc("COMMON.None")});
    }

    return CONST.ACTIVE_EFFECT_TIME_DURATION_UNITS.includes(duration.units)
      ? this._prepareTimeBasedDuration(duration, context)
      : this._prepareCombatBasedDuration(duration, context);
  }

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

  /**
   * Prepare duration data from time-based (minutes, seconds, etc.) source data.
   * @param {EffectDurationData} duration Unprepared duration data
   * @param {object} [context]            Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   * @protected
   */
  _prepareTimeBasedDuration(duration, context) {
    const calendar = game.time.calendar;
    const durationInMonths = duration.units === "months";
    const unitsSingular = durationInMonths ? "day": duration.units.slice(0, -1);
    const avgDaysPerMonth = durationInMonths && calendar.months.values.length
      ? calendar.days.daysPerYear / calendar.months.values.length
      : 0;
    const durationValue = durationInMonths ? Math.ceil(duration.value * avgDaysPerMonth) : duration.value;
    const seconds = calendar.componentsToTime({[unitsSingular]: durationValue});
    const worldTime = game.time.worldTime;

    // Use the current world time as the start time effects lacking one
    const start = this.start ?? {time: worldTime};

    // Handle remaining value and label given the duration is exceeded
    const secondsRemaining = start.time + seconds - worldTime;
    let remainingComponents;
    let formatter;
    let roundFn;
    if ( secondsRemaining < 0 ) {
      // Work around limitations of calendar API when there is a negative difference
      remainingComponents = calendar.difference(worldTime, worldTime + secondsRemaining);
      formatter = "formatAgo";
      roundFn = "floor";
    }
    else {
      if ( secondsRemaining > 0 ) duration.expired = false;
      remainingComponents = calendar.difference(start.time + seconds);
      formatter = "formatDuration";
      roundFn = "ceil";
    }
    let remaining;
    if ( durationInMonths ) {

      // Months may not have a constant number of days and require approximation favoring later rather than earlier
      // expiration
      if ( avgDaysPerMonth === 0 ) remaining = 0;
      else {
        const {secondsPerMinute, minutesPerHour, hoursPerDay} = calendar.days;
        const secondsPerMonth = avgDaysPerMonth * hoursPerDay * minutesPerHour * secondsPerMinute;
        remaining = Math.sign(secondsRemaining) * Math[roundFn](Math.abs(secondsRemaining / secondsPerMonth));
      }
    }
    else {
      const absRemaining = calendar.componentsToUnit(remainingComponents, unitsSingular, {roundFn});
      remaining = Math.sign(secondsRemaining) * absRemaining;
    }
    const formattableComponents = iterateEntries(remainingComponents).reduce((components, [unit, value]) => {
      if ( durationInMonths && (unit === "day") && (value > avgDaysPerMonth) ) return components;
      if ( value && foundry.data.CalendarData._DURATION_FORMAT_UNITS.has(unit) ) components[unit] = value;
      return components;
    }, {});
    const label = calendar.format(formattableComponents, formatter, {style: "short", maxTerms: 2}) || _loc("TIME.Now");
    return Object.assign(duration, {seconds, remaining, secondsRemaining, label});
  }

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

  /**
   * Prepare duration data from combat-based (rounds or turns) source data.
   * @param {EffectDurationData} duration Unprepared duration data
   * @param {object} [context]            Contextual information indicating what lead to this call
   * @returns {ActiveEffectDuration}
   * @protected
   */
  _prepareCombatBasedDuration(duration, context) {
    const unitsSingular = duration.units.replace(/s$/, "");
    /** @type {number} */
    const timeConversion = CONFIG.time[`${unitsSingular}Time`] || 0;
    const seconds = duration.seconds = timeConversion ? Math.trunc(duration.value * timeConversion) : null;
    const worldTime = game.time.worldTime;
    const start = this.start ?? {time: worldTime};
    const combat = game.combats.get(start.combat?.id) ?? game.combat;
    const combatant = combat?.combatants.get(start.combatant) ?? combat?.getCombatantsByActor(this.actor)[0];

    // If no combat information is available, reframe the presented duration as time-based
    if ( !combatant || !combat.started || !combat.turns.length ) {
      return (game._documentsReady && Number.isFinite(seconds))
        ? this._prepareTimeBasedDuration({...duration, units: "seconds", value: seconds})
        : Object.assign(duration, {remaining: Infinity, secondsRemaining: Infinity, label: _loc("COMMON.None")});
    }

    // Acquire the start round and turn number from the combatant if the current combat is not the same as the starting
    // one.
    const [startRound, startTurn] = combat === start.combat
      ? [start.round ?? combatant.roundJoined, (start.turn ?? 0) + 1]
      : [combatant.roundJoined, combatant.turnNumber + 1];

    const currentRound = context?.round ?? combat.round;
    const timeElapsed = timeConversion ? worldTime - start.time : 0;
    switch ( duration.units ) {
      case "rounds": {
        const elapsedInCombat = currentRound - startRound;
        const totalElapsedInCombat = (currentRound - 1);
        const elapsedBeforeCombat = timeConversion
          ? Math.max(0, Math.floor(timeElapsed / timeConversion) - totalElapsedInCombat)
          : 0;
        const remaining = duration.value - (elapsedInCombat + elapsedBeforeCombat);
        const pluralRule = game.i18n.pluralRules.select(Math.abs(remaining));
        const locKey = remaining >= 0 ? "EFFECT.DURATION.ROUNDS" : "EFFECT.DURATION.ROUNDS_AGO";
        Object.assign(duration, {remaining, label: _loc(`${locKey}.${pluralRule}`, {rounds: Math.abs(remaining)})});
        break;
      }
      case "turns": {
        const currentTurn = 1 + (context?.turn ?? combat.turn);
        const turnsPerRound = combat.turns.length;
        const elapsedPriorRounds = Math.max(0,
          (turnsPerRound - startTurn) + (turnsPerRound * (currentRound - startRound - 1)));
        const elapsedThisRound = startRound === currentRound ? Math.max(0, currentTurn - startTurn) : currentTurn;
        const elapsedInCombat = elapsedPriorRounds + elapsedThisRound;
        const elapsedBeforeCombat = timeConversion ? Math.floor(timeElapsed / timeConversion) - elapsedInCombat : 0;
        const remaining = duration.value - (elapsedInCombat + elapsedBeforeCombat);
        const pluralRule = game.i18n.pluralRules.select(Math.abs(remaining));
        const locKey = remaining >= 0 ? "EFFECT.DURATION.TURNS" : "EFFECT.DURATION.TURNS_AGO";
        Object.assign(duration, {remaining, label: _loc(`${locKey}.${pluralRule}`, {turns: Math.abs(remaining)})});
        break;
      }
    }
    if ( timeConversion ) duration.secondsRemaining = duration.remaining * timeConversion;
    else delete duration.secondsRemaining;
    return duration;
  }

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

  /** @inheritDoc */
  toCompendium(pack, options) {
    const data = super.toCompendium(pack, options);
    if ( options?.clearState !== false ) {
      data.origin = null;
      data.start = null;
    }
    return data;
  }

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

  /**
   * Determine whether a change from this ActiveEffect should be applied during the current phase. Systems and modules
   * may override this method to introduce additional conditions under which a change is applied.
   * @param {ActiveEffectChangeData} change    The change being considered.
   * @param {object} [options]                 Options which affect whether the change is applied.
   * @param {string} [options.phase]           The application phase currently being evaluated.
   * @param {string} [options.replacementData] Replacement data to be used as part of the change's application
   * @returns {boolean}                        Should the change be applied during this phase (or at all)?
   */
  shouldApplyChange(change, options) {
    return change.phase === options?.phase;
  }

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

  /**
   * Acquire replacement data for use in the application of this effect's changes.
   * @param {object} baseData Base data sourced from elsewhere (by default from `Actor#getRollData`)
   * @returns {object}        Data used to resolve "@" expressions in string {@link ActiveEffectChangeData} values
   */
  getReplacementData(baseData) {
    return baseData;
  }

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

  /**
   * Apply this ActiveEffect to a target Document.
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {ActiveEffectChangeData} change      The change data being applied
   * @param {object} [options]                   Options affecting the change application
   * @param {object} [options.replacementData]   Data used to resolve "@" expressions in a string value
   * @param {boolean} [options.modifyTarget]     Modify the target Document with the updated value.
   * @returns {Record<string, unknown>} An object of property keys and their updated values
   */

  static applyChange(targetDoc, change, {replacementData={}, modifyTarget=true}={}) {
    let field;
    const changes = {};
    if ( (typeof change.key === "string") && change.key.startsWith("system.") ) {
      if ( targetDoc.system instanceof foundry.abstract.DataModel ) {
        field = targetDoc.system.getFieldForProperty(change.key.slice(7));
      }
    }
    else field = targetDoc.getFieldForProperty(change.key ?? "");
    const configuredHandler = ActiveEffect.CHANGE_TYPES[change.type]?.handler;
    if ( typeof configuredHandler === "function" ) {
      configuredHandler(targetDoc, change, {field, replacementData, modifyTarget});
    }
    else if ( field ) {
      if ( foundry.utils.getDefiningClass(this, "applyField") !== ActiveEffect ) {
        foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides applyField, which is"
          + " deprecated. Please override applyChangeField instead.", {since: 14, until: 16, once: true});
        changes[change.key] = this.applyField(targetDoc, change, field);
      }
      else changes[change.key] = this.applyChangeField(targetDoc, change, {field, replacementData, modifyTarget});
    }
    else {
      if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyLegacy") !== ActiveEffect) ) {
        foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyLegacy, which is"
          + " deprecated. Please override static _applyChangeUnguided instead.", {since: 14, until: 16, once: true});
        change.effect._applyLegacy(targetDoc, change, changes);
      }
      else this._applyChangeUnguided(targetDoc, change, changes, {replacementData, modifyTarget});
    }
    return changes;
  }

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

  /**
   * Apply EffectChangeData to a field within a Document.
   * @param {Actor|Item|TokenDocument} targetDoc The model instance.
   * @param {EffectChangeData} change            The change to apply.
   * @param {object} [options]                   Additional options to configure the change application.
   * @param {DataField} [options.field]          The field: if not supplied, it will be retrieved from the supplied
   *                                             Document.
   * @param {Record<string, unknown>} [options.replacementData] Data used to resolve "@" expressions.
   * @param {boolean} [options.modifyTarget]     Modify the target Document with the updated value.
   * @returns {unknown} The updated value.
   */
  static applyChangeField(targetDoc, change, {field, replacementData={}, modifyTarget=true}={}) {
    field ??= targetDoc.getFieldForProperty(change.key);
    const current = foundry.utils.getProperty(targetDoc, change.key);
    const update = field.applyChange(current, targetDoc, change, {replacementData});
    if ( modifyTarget && (update !== undefined) ) foundry.utils.setProperty(targetDoc, change.key, update);
    return update;
  }

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

  /**
   * Apply this ActiveEffect to a provided Actor using a heuristic to infer the value types based on the current value
   * and/or the default value in the template.json.
   * @param {Actor|Item|TokenDocument|DataModel} targetDoc  The Document or DataModel to which this effect should be
   *                                                        applied
   * @param {ActiveEffectChangeData} change                 The change data being applied.
   * @param {Record<string, unknown>} changes               The aggregate update paths and their updated values.
   * @param {object} [options]
   * @param {Record<string, unknown>} [options.replacementData] Data used to resolve "@" expressions.
   * @param {boolean} [options.modifyTarget]                    Modify the target Document with the updated value.
   * @protected
   */
  static _applyChangeUnguided(targetDoc, change, changes, {replacementData={}, modifyTarget=true}={}) {
    // Determine the data type of the target field
    const {getProperty, getType} = foundry.utils;
    const current = getProperty(targetDoc, change.key) ?? null;
    let targetData = current;
    if ( (current === null) && targetDoc.documentName ) {
      const model = game.model[targetDoc.documentName]?.[targetDoc.type] ?? {};
      targetData = getProperty(model, change.key) ?? null;
    }
    const targetType = getType(targetData);

    // Cast the effect change value to the correct type
    let delta;
    try {
      if ( ["Array", "Set"].includes(targetType) ) {
        const innerType = targetData.length
          ? getType(targetData[0])
          : targetData.size
            ? getType(targetData.first())
            : "string";
        delta = ActiveEffect.#castArray(change.value, innerType, replacementData);
        if ( targetType === "Set" ) delta = new Set(delta);
      }
      else delta = ActiveEffect.#castDelta(change.value, targetType, replacementData);
    } catch(error) {
      const header = change.effect ? `Active Effect (${change.effect.uuid}) |` : "";
      console.warn(`${header} "${change.type}" change to "${change.key}" failed to resolve: ${error.message}`);
      return;
    }

    // Apply the change depending on the application mode
    switch ( change.type ) {
      case "add":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyAdd") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyAdd, which is"
            + " deprecated. Please override static _applyChangeAdd instead.", {since: 14, until: 16, once: true});
          change.effect._applyAdd(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeAdd(targetDoc, change, current, delta, changes);
        break;
      case "subtract":
        this._applyChangeSubtract(targetDoc, change, current, delta, changes);
        break;
      case "multiply":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyMultiply") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyMultiply, which is"
            + " deprecated. Please override static _applyChangeMultiply instead.", {since: 14, until: 16, once: true});
          change.effect._applyMultiply(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeMultiply(targetDoc, change, current, delta, changes);
        break;
      case "override":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyOverride") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyOverride, which is"
            + " deprecated. Please override static _applyChangeOverride instead.", {since: 14, until: 16, once: true});
          change.effect._applyOverride(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeOverride(targetDoc, change, current, delta, changes);
        break;
      case "upgrade":
      case "downgrade":
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyUpgrade") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyUpgrade, which is"
            + " deprecated. Please override static _applyChangeUpgrade instead.", {since: 14, until: 16, once: true});
          change.effect._applyUpgrade(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeUpgrade(targetDoc, change, current, delta, changes);
        break;
      default:
        if ( change.effect && (foundry.utils.getDefiningClass(change.effect, "_applyCustom") !== ActiveEffect) ) {
          foundry.utils.logCompatibilityWarning("The ActiveEffect implementation overrides _applyCustom, which is"
            + " deprecated. Please override static _applyChangeCustom instead.", {since: 14, until: 16, once: true});
          change.effect._applyCustom(targetDoc, change, current, delta, changes);
        }
        else this._applyChangeCustom(targetDoc, change, current, delta, changes);
        break;
    }

    // Apply all changes to the Actor data
    if ( modifyTarget ) foundry.utils.mergeObject(targetDoc, changes);
  }

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

  /**
   * Cast raw EffectChangeData change data to the desired data type.
   * @param {unknown} raw              The raw value
   * @param {string} type              The target data type that the raw value should be cast to match
   * @param {Record<string, unknown>} [replacementData] Data used to resolve "@" expressions
   * @returns {unknown}                The parsed delta cast to the target data type
   * @throws {Error}                   An error in case of data-replacement failure
   */
  static #castDelta(raw, type, replacementData={}) {
    let delta;
    if ( typeof raw === "string" ) raw = ActiveEffect.implementation._replaceDataRefs(raw, replacementData);
    switch ( type ) {
      case "boolean":
        delta = !!raw;
        break;
      case "number":
        delta = Number(raw) || 0;
        break;
      case "string":
        delta = String(raw);
        break;
      default:
        delta = raw;
    }
    return delta;
  }

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

  /**
   * Recursively replace data references in a string change value.
   * @param {string} raw
   * @param {Record<string, unknown>} data An object providing replacements
   * @returns {string|null} The string with all data references resolved
   * @throws {Error} An Error if data replacement failed
   * @protected
   */
  static _replaceDataRefs(raw, data) {
    return Roll.defaultImplementation.replaceFormulaData(raw, data, {recursive: true, warn: true});
  }

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

  /**
   * Cast a raw EffectChangeData change data to an Array of an inner type.
   * @param {unknown} data             The change data
   * @param {string} type              The target data type of inner array elements
   * @param {Record<string, unknown>} [replacementData] Data used to resolve "@" expressions
   * @returns {unknown[]}              The parsed delta cast as a typed array
   */
  static #castArray(data, type, replacementData={}) {
    const delta = Array.isArray(data) ? data : [data];
    return delta.map(d => ActiveEffect.#castDelta(d, type, replacementData));
  }

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

  /**
   * Apply an ActiveEffect that uses an "add" change type.
   * The way that effects are added depends on the data type of the current value.
   *
   * If the current value is null, the change value is assigned directly.
   * If the current type is a string, the change value is concatenated.
   * If the current type is a number, the change value is cast to numeric and added.
   * If the current type is an array, the change value is appended to the existing array if it matches in type.
   *
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeAdd(targetDoc, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
        update = current || delta;
        break;
      case "null":
        update = delta;
        break;
      case "Array":
        update = current.concat(delta);
        break;
      case "Set":
        update = current.union(delta);
        break;
      default:
        update = current + delta;
        break;
    }
    if ( update !== current ) changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses a "subtract" change type.
   * The way that effects are added depends on the data type of the current value.
   *
   * If the current value is null, the change value is assigned directly.
   * If the current type is a string, the change value is replaced in the current value with the empty string.
   * If the current type is a number, the change value is cast to numeric and subtracted.
   * If the current type is an array, the change value is spliced out of the array if present.
   *
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeSubtract(targetDoc, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
        update = current !== delta;
        break;
      case "null":
        update = delta;
        break;
      case "string":
        update = current.replace(delta, "");
        break;
      case "Array":
        update = [...current].findSplice(e => foundry.utils.equals(e, delta));
        break;
      case "Set":
        update = current.difference(delta);
        break;
      default:
        update = current - delta;
        break;
    }
    if ( update !== current ) changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses a MULTIPLY application mode.
   * Changes which MULTIPLY must be numeric to allow for multiplication.
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeMultiply(targetDoc, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
        update = current && delta;
        break;
      case "number":
        update = current * delta;
        break;
    }
    if ( (update !== current) && (update !== undefined) ) changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses an OVERRIDE application mode.
   * Numeric data is overridden by numbers, while other data types are overridden by any value
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeOverride(targetDoc, change, current, delta, changes) {
    if ( delta !== current ) changes[change.key] = delta;
  }

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

  /**
   * Apply an ActiveEffect that uses an UPGRADE, or DOWNGRADE application mode.
   * Changes which UPGRADE or DOWNGRADE must be numeric to allow for comparison.
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeUpgrade(targetDoc, change, current, delta, changes) {
    let update;
    const ct = foundry.utils.getType(current);
    switch ( ct ) {
      case "boolean":
      case "number":
        if ( (change.type === "upgrade") && (delta > current) ) update = delta;
        else if ( (change.type === "downgrade") && (delta < current) ) update = delta;
        break;
      case "Set": {
        if ( (change.type === "upgrade") && delta.isSupersetOf(current) ) update = delta;
        else if ( (change.type === "downgrade") && delta.isSubsetOf(current) ) update = delta;
        break;
      }
    }
    if ( (update !== current) && (update !== undefined) ) changes[change.key] = update;
  }

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

  /**
   * Apply an ActiveEffect that uses a CUSTOM change type.
   * @param {Actor|Item|TokenDocument} targetDoc The Document to which this effect should be applied
   * @param {EffectChangeData} change            The change data being applied
   * @param {unknown} current                    The current value being modified
   * @param {unknown} delta                      The parsed value of the change object
   * @param {object} changes                     An object which accumulates changes to be applied
   * @protected
   */
  static _applyChangeCustom(targetDoc, change, current, delta, changes) {
    const preHook = foundry.utils.getProperty(targetDoc, change.key);
    Hooks.call("applyActiveEffect", targetDoc, change, current, delta, changes);
    const postHook = foundry.utils.getProperty(targetDoc, change.key);
    if ( (postHook !== preHook) && (postHook !== undefined) ) changes[change.key] = postHook;
  }

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

  /**
   * A determination of whether the ActiveEffect's expiry event was reached. This check is independent of whether the
   * duration was also reached.
   * @param {string} event     The event that triggered this check
   * @param {object} [context] Contextual information for use in the determination
   * @returns {boolean}
   */
  isExpiryEvent(event, context) {
    const expiry = this.duration.expiry;
    if ( event === "updateWorldTime" ) {
      // Expiry is determined by duration alone
      if ( !expiry ) return true;
      // Outside of combat, treat any time advancement as satisfying in-combat expiry events
      return !this.actor?.inCombat && CORE_EXPIRY_EVENTS.includes(expiry) && expiry !== "combatStart";
    }
    if ( event !== expiry ) return false;
    if ( !CORE_EXPIRY_EVENTS.includes(expiry) ) return true;

    /** @type {Combat|null} */
    const combat = context.combat ?? game.combat;
    /** @type {Combatant|null|undefined} */
    const effectCombatant = combat?.started
      ? combat === this.start.combat
        ? combat.combatants.get(this.start.combatant)
        : combat.getCombatantsByActor(this.actor ?? "")[0]
      : null;
    switch ( event ) {
      case "combatStart":
      case "combatEnd":
      case "roundStart":
      case "roundEnd":
        return !!effectCombatant;
      case "turnStart":
        return !!combat?.started && (combat.combatant === effectCombatant);
      case "turnEnd": {
        if ( !combat?.started || !effectCombatant ) return false;
        const previousCombatantId = combat.previous.combatantId;
        if ( previousCombatantId ) return effectCombatant.id === previousCombatantId; // Prefer matching on combatant
        else return effectCombatant.turnNumber === combat.previous.turn;              // Otherwise match turn number
      }
      default:
        return false;
    }
  }

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

  /**
   * Retrieve the initial duration configuration.
   * @param {Combat|null} [combat]
   * @returns {EffectStartData}
   */
  static getEffectStart(combat=game.combat) {
    if ( !combat?.started ) combat = null;
    const combatant = combat?.combatant;
    return {
      time: game.time.worldTime,
      combat: combat?.id ?? null,
      combatant: combatant?.id ?? null,
      initiative: combatant?.initiative ?? null,
      round: combat?.round ?? null,
      turn: combat?.turn ?? null
    };
  }

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

  /** @inheritDoc */
  async _preCreate(data, options, user) {
    const allowed = await super._preCreate(data, options, user);
    if ( allowed === false ) return false;

    // Set initial start data for Actor-owned effects
    if ( this.parent instanceof Actor ) {
      const start = this.constructor.getEffectStart();
      for ( const key of Object.keys(start) ) {
        if ( data.start?.[key] !== undefined ) delete start[key]; // Prefer user-defined duration data
      }
      this.updateSource({start});
    }
  }

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

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(true);
  }

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

  /** @inheritDoc */
  async _preUpdate(data, options, user) {
    const allowed = await super._preUpdate(data, options, user);
    if ( allowed === false ) return false;
    if ( !data.duration ) return;

    // Reset duration units to seconds when the value is wiped
    if ( data.duration.value === null ) data.duration.units = "seconds";
  }

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


  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);
    if ( !(this.target instanceof Actor) ) return;
    const activeChanged = "disabled" in changed;
    if ( activeChanged && (options.animate !== false) ) this._displayScrollingStatus(this.active);
  }

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

  /** @inheritDoc */
  _onDelete(options, userId) {
    super._onDelete(options, userId);
    ActiveEffect.registry.delete(this);
    if ( this.modifiesActor && (options.animate !== false) ) this._displayScrollingStatus(false);
  }

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

  /**
   * Display changes to active effects as scrolling Token status text.
   * @param {boolean} enabled     Is the active effect currently enabled?
   * @protected
   */
  _displayScrollingStatus(enabled) {
    if ( !(this.statuses.size || this.system.changes.length) ) return;
    const actor = this.target;
    const tokens = actor.getActiveTokens(true);
    const text = `${enabled ? "+" : "−"}(${this.name})`;
    for ( const token of tokens ) {
      if ( !token.visible || token.document.isSecret ) continue;
      canvas.interface.createScrollingText(token.center, text, {
        anchor: CONST.TEXT_ANCHOR_POINTS.CENTER,
        direction: enabled ? CONST.TEXT_ANCHOR_POINTS.TOP : CONST.TEXT_ANCHOR_POINTS.BOTTOM,
        distance: (2 * token.h),
        fontSize: 28,
        stroke: 0x000000,
        strokeThickness: 4,
        jitter: 0.25
      });
    }
  }

  /* -------------------------------------------- */
  /*  Deprecations and Compatibility              */
  /* -------------------------------------------- */

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  apply(actor, change) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#apply, which has been moved to"
      + " ActiveEffect.applyChange", {since: 14, until: 16, once: true});
    return this.constructor.applyChange(actor, change);
  }

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

  /**
   * @deprecated since v14
   * @ignore
   */
  static applyField(model, change, field) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect.applyField, which has been moved to"
      + " ActiveEffect.applyChangeField.", {since: 14, until: 16, once: true});
    return this.applyChangeField(model, change, {field});
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyLegacy(actor, change, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyLegacy, which has been moved to"
    + " ActiveEffect._applyChangeUnguided", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeUnguided(actor, change, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyAdd(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyAdd, which has been moved to"
    + " ActiveEffect._applyChangeAdd", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeAdd(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyMultiply(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyMultiply, which has been moved to"
    + " ActiveEffect._applyChangeMultiply", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeMultiply(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyOverride(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyOverride, which has been moved to"
    + " ActiveEffect._applyChangeOverride", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeOverride(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyUpgrade(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyUpgrade, which has been moved to"
    + " ActiveEffect._applyChangeUpgrade", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeUpgrade(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {unknown}
   * @deprecated since v14
   * @ignore
   */
  _applyCustom(actor, change, current, delta, changes) {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect#_applyCustom, which has been moved to"
    + " ActiveEffect._applyChangeCustom", {since: 14, until: 16, once: true});
    return this.constructor._applyChangeCustom(actor, change, current, delta, changes);
  }

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

  /**
   * @returns {{start: EffectStartData}}
   * @deprecated since v14
   * @ignore
   */
  static getInitialDuration() {
    foundry.utils.logCompatibilityWarning("You are accessing ActiveEffect.getInitialDuration,"
      + " which has been moved to ActiveEffect.getEffectStart.", {since: 14, until: 16, once: true});
    return {start: this.getEffectStart()};
  }
}

Object.defineProperty(ActiveEffect, "registry", {writable: false, configurable: false});
