import DataModel from "@common/abstract/data.mjs";
import {AnyField, ArrayField, SchemaField, StringField, TypedObjectField,
  TypedSchemaField} from "@common/data/fields.mjs";
import {DataModelValidationError} from "@common/data/validation-failure.mjs";
import {deepClone, iterateValues, mergeObject} from "@common/utils/helpers.mjs";
import VFXReferenceField from "./fields/vfx-reference-field.mjs";

/**
 * @import {TimerOptions, TimelineOptions, DefaultsParams, Timeline} from "animejs"
 * @import {DataModelConstructionContext} from "@common/abstract/_types.mjs";
 * @import VFXComponent from "./vfx-component.mjs";
 */

/**
 * @typedef VFXEffectData
 * @property {string} name
 * @property {Record<string, object>} components
 * @property {VFXTimelineSequenceEntry[]} timeline
 */

/**
 * @typedef VFXTimelineSequenceEntry
 * @property {string} component             The labeled component in sequence.
 * @property {number|string} [position]     The animejs offset position. Can be a number for absolute timeline values,
 *                                          a label (including optional prefixing to reference other effects),
 *                                          or a relative value (e.g. "+1000" for 1s after the previous effect).
 *                                          It can also be a combination of label and offset (e.g.
 *                                          "effect.impact-=1000" for 1s before the `effect.impact` label)
 */

/**
 * A specialized DataModel subclass used to configure VFXEffects in a way that can be serialized for storage in
 * database or transmission over socket. VFXEffect instances involve animation components which are animated over a
 * configured timeline.
 * Playback for a VFXEffect can only happen once. The intended paradigm to repeat an effect multiple times requires
 * cloning it before each successive playback.
 *
 * @extends {DataModel<VFXEffectData, DataModelConstructionContext>}
 * @mixes VFXEffectData
 */
export default class VFXEffect extends DataModel {

  /** @override */
  static defineSchema() {
    const components = Object.values(CONFIG.Canvas.vfx.components).reduce((obj, cls) => {
      obj[cls.TYPE] = cls;
      return obj;
    }, {});
    return {
      name: new StringField({required: true, nullable: false, blank: false}),
      components: new TypedObjectField(new TypedSchemaField(components)),
      timeline: new ArrayField(new SchemaField({
        component: new StringField({required: true, nullable: false, blank: false}),
        position: new AnyField({
          required: false,
          nullable: false,
          validate: data => {
            if (data == null || (typeof data === "string" && data !== "") || typeof data === "number") return true;
            throw new Error("Timeline position must be a non-empty string or a number");
          }
        })
      }))
    };
  }

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

  /**
   * Is this VFXEffect currently playing?
   * @type {boolean}
   */
  get playing() {
    return !!this.#animationPromise;
  }

  /**
   * An animejs Timeline instance used to orchestrate playback.
   * @type {animejs.Timeline}
   */
  #animejsTimeline;

  /**
   * An active Promise that resolves once animation of this effect is complete.
   * @type {PromiseWithResolvers<boolean>}
   */
  #animationPromise;

  /**
   * VFXComponents that are included in this animation
   * @type {Record<string, VFXComponent>}
   */
  components = this.components;

  /**
   * Has playback of this effect started?
   * @type {boolean}
   */
  get started() {
    return !!this.#started;
  }

  #started = false;

  /**
   * A VFXEffect may contain references that are resolved at runtime. Such references may only be resolved once per
   * playback lifecycle. Keep track of the unresolved source data for this effect so that the effect can be cloned
   * for reuse with different references.
   * @type {VFXEffectData}
   */
  #unresolvedSource;

  /* -------------------------------------------- */
  /*  Animation Lifecycle                         */
  /* -------------------------------------------- */

  /**
   * Load necessary materials for all animation components.
   * @returns {Promise<void>}
   */
  async load() {
    const promises = [];
    for ( const component of iterateValues(this.components) ) promises.push(component.load());
    await Promise.all(promises);
  }

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

  /**
   * Perform initial drawing of every animation component.
   * @returns {Promise<void>}
   */
  async draw() {
    const promises = [];
    for ( const component of iterateValues(this.components) ) promises.push(component.draw());
    await Promise.all(promises);
    this.#buildTimeline();
  }

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

  /**
   * Add managed display objects for each component to the primary canvas container.
   */
  attach() {
    for ( const component of iterateValues(this.components) ) component.attach();
  }

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

  /** @override */
  clone(data={}, context={}) {
    const source = deepClone(this.#unresolvedSource) ?? this.toObject();
    data = mergeObject(source, data, {insertKeys: false, applyOperators: true, inplace: true});
    return new this.constructor(data, {...context, strict: false});
  }

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

  /**
   * Begin playback of a VFXEffect using provided reference data.
   * @param {Record<string, any>} [references]    A record of references used to resolve model data
   * @returns {Promise<boolean>}                  A Promise which resolves to signal whether playback fully completed
   * @throws {Error}                              An error if playback from this VFXEffect has already started or if
   *                                              playback failed for some reason.
   */
  async play(references={}) {

    // Temporarily gate the VFX framework behind an opt-in flag.
    if ( !CONFIG.Canvas.vfx.enabled ) {
      console.error("The Foundry VTT version 14 VFX framework is not active. You may use this experimental framework"
        + " by enabling it via `CONFIG.Canvas.vfx.enabled`.");
      return;
    }

    // VFXEffect instances can only be played once
    if ( this.#started ) {
      throw new Error("VFXEffect instances may only be played once and may not be reused. To reuse an effect which"
       + " has already been played, first call VFXEffect#clone which resets it to a playable state");
    }
    this.#started = true;
    this.#animationPromise = Promise.withResolvers();

    // Catch errors during playback for safe teardown
    try {
      this.#animejsTimeline = animejs.createTimeline({autoplay: false});

      // Resolve remaining references using provided data
      this.resolveReferences(references);

      // Load component materials, draw display objects, and attach them to the primary canvas group
      await this.load();
      await this.draw();
      this.attach();

      // Schedule cleanup on timeline completion
      this.#animejsTimeline.then(() => this.#finalize(true, {terminateTimeline: false}));

      // Begin playback
      this.#animejsTimeline.play();
    } catch(err) {
      await this.#finalize(false, {terminateTimeline: true});
      throw err;
    }

    // Return the animation promise
    return this.#animationPromise.promise;
  }

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

  /**
   * Stop animation, but treat the animation as successfully completed.
   * @returns {Promise<void>}
   */
  async stop() {
    await this.#finalize(true, {terminateTimeline: true});
  }

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

  /**
   * Cancel animation and treat the animation as unsuccessful.
   * @returns {Promise<void>}
   */
  async cancel() {
    await this.#finalize(false, {terminateTimeline: true});
  }

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

  /**
   * Finalize the animation, either as success or as failure/cancellation.
   * @param {boolean} success
   * @param {object} [options]
   * @param {boolean} [options.terminateTimeline]
   * @returns {Promise<void>}
   */
  async #finalize(success, {terminateTimeline=false}={}) {
    if ( terminateTimeline ) this.#animejsTimeline?.cancel();
    const method = success ? "stop" : "cancel";
    await Promise.allSettled(Object.values(this.components).map(c => c[method]()));
    this.#animationPromise?.resolve(success);
    this.#animationPromise = undefined;
  }

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

  /**
   * Construct and populate the animejs timeline with labels required by each animation component.
   */
  #buildTimeline() {
    const timeline = this.#animejsTimeline;
    timeline.label("start");

    // Set of added component timelines to warn about missing dependencies
    const addedTimelines = new Set();
    let endMS = 0;
    for (const entry of this.timeline) {
      const component = this.components[entry.component];
      if ( !component ) {
        console.error(`VFXEffect: Component ${entry.component} not found`);
        continue;
      }

      // Add labels from the component
      const label = entry.component;
      if (addedTimelines.has(label)) {
        console.warn(`VFXEffect: Timeline entry for ${entry.component} already exists. Each component can only
        have one timeline entry.`);
      }
      addedTimelines.add(label);
      timeline.label(label, entry.position);
      timeline.sync(component.timeline, entry.position);

      // Add scoped component timeline labels to the effect timeline
      const offset = timeline.labels[label];
      const timelineSubLabels = component.timeline.labels;
      for (const [subLabel, subOffset] of Object.entries(timelineSubLabels)) {
        timeline.label(`${label}.${subLabel}`, offset + subOffset);
        endMS = Math.max(endMS, offset + subOffset);
      }
    }
    timeline.label("end", endMS);
  }

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

  /**
   * Resolve all reference fields within this model against provided reference data.
   * This is idempotent: fields that have already been resolved to concrete values are skipped on subsequent calls.
   * This is invoked automatically as part of {@link VFXEffect#play}, after which point all references must be final.
   * It can be called manually before {@link VFXEffect#play} to assume direct control over reference resolution before
   * effect playback begins.
   * @param {Record<string, any>} [references]  A record of references used to resolve model data
   * @throws {DataModelValidationError|Error}   An error if references were unable to resolve
   */
  resolveReferences(references={}) {
    this.#unresolvedSource ??= deepClone(this._source);

    // Resolve into a COMPLETE data tree: references are replaced and every other present value is passed
    // through unchanged. A complete tree is required because updateSource replaces arrays atomically - a
    // partial update carrying only the resolved reference field of an array-of-object element would drop
    // that element's sibling data when the array is replaced. The `resolved` flag preserves the no-op fast
    // path so a tree with no references to resolve does not trigger updateSource (which would re-initialize).
    let resolved = false;
    const resolvedUpdates = this.schema.apply(function(source, _options) {
      if ( !(this instanceof VFXReferenceField) ) return source;
      if ( !VFXReferenceField.isReference(source) ) return source;
      resolved = true;
      return this.resolve(source, references);
    }, this._source, {partial: true});
    if ( !resolved ) return;
    try {
      this.updateSource(resolvedUpdates);
    }
    catch(error) {
      if (error instanceof DataModelValidationError) {
        /** @type {Set<string>} */
        const failedReferenceKeys = new Set();
        const referenceFailures = Object.keys(error.getAllFailures()).flatMap(key => {
          const referenceData = foundry.utils.getProperty(this._source, key);
          const referenceKey = `${referenceData?.reference}|${referenceData?.property}`;
          if (failedReferenceKeys.has(referenceKey)) return [];
          failedReferenceKeys.add(referenceKey);
          return referenceData;
        });
        const failed = referenceFailures.map(ref => JSON.stringify(ref, null, 2)).join("\n");
        let details = "Error playing VFXEffect. Validation failed after resolving references.";
        details += `\nProvided references: ${JSON.stringify(references, null, 2)}`;
        details += `\nThe following references failed to resolve: ${failed}`;
        throw new Error(details, {cause: error});
      }
      throw error;
    }
  }
}
