import {IterableWeakSet} from "@common/utils/_module.mjs";

/**
 * @import {ActiveEffect, Actor, Combat, Combatant, Item} from "@client/documents/_module.mjs";
 */

/**
 * A singleton helper class that tracks the duration and expiry of {@link ActiveEffect}s
 * @extends IterableWeakSet<ActiveEffect>
 */
export default class ActiveEffectRegistry extends IterableWeakSet {

  /**
   * Has the registry been populated for the first time?
   * @type {boolean}
   */
  get initialized() {
    return this.#initialized;
  }

  #initialized = false;

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

  /**
   * Populate the registry for the first time.
   * @internal
   */
  _initialize() {
    if ( !game._documentsReady || this.#initialized ) return;

    // Register effects for base actors.
    // Effects for synthetic ActorDelta actors are registered lazily in TokenDocument#_onDeltaMaterialized
    for ( const actor of game.actors ) this.addFromParent(actor);

    // Re-initialize tracked durations
    for ( const effect of this ) effect.updateDuration();
    this.#initialized = true;
  }

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

  /**
   * Register a single ActiveEffect document. If the document is already registered but no longer eligible for
   * registration, it will be deleted.
   * @param {ActiveEffect} effect
   * @override
   */
  add(effect) {
    if ( !game._documentsReady ) return this;
    if ( effect.isExpiryTrackable ) super.add(effect);
    else this.delete(effect);
    return this;
  }

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

  /**
   * Register the ActiveEffects embedded on an Actor or Item.
   * @param {Actor|Item} document
   * @returns {this}
   */
  addFromParent(document) {
    if ( !game._documentsReady ) return this;
    for ( const effect of document.effects ) {
      this.add(effect);
    }
    if ( document.documentName === "Actor" ) {
      for ( const item of document.items ) {
        this.addFromParent(item);
      }
    }
    return this;
  }

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

  /**
   * Unregister the ActiveEffects embedded on an Actor or Item.
   * @param {Actor|Item} document
   * @returns {boolean} Did any deletions occur?
   */
  deleteFromParent(document) {
    if ( !game._documentsReady ) return false;
    let deletionsOccurred = false;
    for ( const effect of document.effects ) {
      if ( this.delete(effect) ) deletionsOccurred = true;
    }
    if ( document.documentName === "Actor" ) {
      for ( const item of document.items ) {
        if ( this.deleteFromParent(item) ) deletionsOccurred = true;
      }
    }
    return deletionsOccurred;
  }

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

  /**
   * Refresh the durations of registered ActiveEffects and perform the configured action for expired effects.
   * @param {string} event                The expiry or other event that triggered this call
   * @param {object} [context]            Additional contextual data relevant to the event
   * @param {Combat} [context.combat]     The Combat associated with this event
   * @param {Set<Actor>} [context.actors] Limit the refresh to effects belonging to the provided list of actors.
   * @see {@link CONFIG.ActiveEffect.expiryAction}
   */
  async refresh(event, context) {

    // Setup per-parent groupings for batched modifications and callbacks
    /** @type {Map<Actor, ActiveEffect[]>} */
    const refreshed = new Map();
    /** @type {Map<Actor|Item, ActiveEffect[]>} */
    const expired = new Map();

    for ( const effect of this ) {
      if ( context?.actors && !context.actors.has(effect.actor) ) continue;

      // These events never entail a change in remaining duration
      // Combat start and end also don't but can lead to a duration getting reframed as time-based or combat-based
      if ( !["roundEnd", "turnEnd"].includes(event) ) {
        const oldRemaining = effect.duration.secondsRemaining ?? effect.duration.remaining;
        const oldUnits = effect.duration.units;
        const updated = effect.updateDuration(context);
        const newRemaining = updated.secondsRemaining ?? updated.remaining;
        const remainingChanged = oldRemaining !== newRemaining;
        const unitsChanged = oldUnits !== updated.units;
        if ( remainingChanged || unitsChanged || (newRemaining <= 0) || (newRemaining === Infinity) ) {
          if ( !refreshed.has(effect.actor) ) refreshed.set(effect.actor, []);
          refreshed.get(effect.actor).push(effect);
        }
      }
      const durationReached = (effect.duration.remaining <= 0) || !Number.isFinite(effect.duration.remaining);
      if ( durationReached && effect.isExpiryEvent(event, context) ) {
        this.delete(effect);
        if ( !expired.has(effect.parent) ) expired.set(effect.parent, []);
        expired.get(effect.parent).push(effect);
      }
    }

    // Notify actors of updated durations
    for ( const [actor, effects] of refreshed.entries() ) {
      actor.onUpdateEffectDurations(effects, event, context);
    }

    // Perform the configured action for expired effects
    if ( game.users.activeGM?.isSelf ) {
      if ( CONFIG.ActiveEffect.expiryAction === "update" ) await this.#updateExpiredEffects(expired);
      else if ( CONFIG.ActiveEffect.expiryAction === "delete" ) await this.#deleteExpiredEffects(expired);
    }
  }

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

  /**
   * Update the duration.expired value for the provided expired effects.
   * @param {Map<Actor|Item, ActiveEffect[]>} effects
   */
  async #updateExpiredEffects(effects) {
    const operations = effects.entries().map(([parent, effects]) => ({
      action: "update",
      documentName: "ActiveEffect",
      parent,
      updates: effects.map(e => ({_id: e._id, duration: {expired: true}}))
    })).toArray();
    await foundry.documents.modifyBatch(operations);
  }

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

  /**
   * Delete the provided expired effects.
   * @param {Map<Actor|Item, ActiveEffect[]>} effects
   */
  async #deleteExpiredEffects(effects) {
    const operations = effects.entries().map(([parent, effects]) => ({
      action: "delete",
      documentName: "ActiveEffect",
      parent,
      ids: effects.map(e => e._id)
    })).toArray();
    await foundry.documents.modifyBatch(operations);
  }
}
