import {AlphaField, ArrayField, BooleanField, NumberField, ObjectField, StringField,
  SchemaField} from "@common/data/fields.mjs";
import VFXComponent from "../vfx-component.mjs";
import {VFXReferencePointField, VFXReferenceObjectField, VFXReferenceField} from "../fields/_module.mjs";
import VFXCanvasContainer from "../vfx-canvas-container.mjs";
import {SOUND_ALIGNMENT} from "@client/canvas/vfx/vfx-constants.mjs";
import {resolveAnimation} from "@client/canvas/vfx/vfx-utils.mjs";

/**
 * @import {VFXBasePathPoint, VFXPositionalSoundData} from "../_types.mjs";
 */

/**
 * @typedef VFXSingleImpactData
 * @property {VFXBasePathPoint} position
 * @property {string} texture
 * @property {VFXPositionalSoundData|null} sound
 * @property {number} duration
 * @property {number} [size]
 * @property {{x: number, y: number}} [scale]
 * @property {{function: string, params: object}[]} [animations]
 */

/**
 * A VFX component used to add a single impact effect at a certain location.
 * This component handles basic impacts that can be represented as the combination of a sprite and a sound.
 *
 * @example A blood splatter impact
 * ```js
 * const vfxConfig = {
 *   name: "bloodSplatter",
 *   components: {
 *     splash: {
 *       type: "singleImpact",
 *       position: {reference: "target", deltas: {sort: 1}},
 *       texture: "assets/impact/BloodSplatter1.png",
 *       size: 2,
 *       duration: 2000,
 *       sound: {
 *         src: "assets/sounds/ArrowHit1.wav",
 *         align: 1
 *       }
 *     }
 *   },
 *   timeline: [{component: "splash"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * const target = game.user.targets.first();
 * effect.play({
 *   target: {...target.center, elevation: target.document.elevation, sort: target.document.sort}
 * });
 * ```
 * @extends {VFXComponent<VFXSingleImpactData>}
 */
export default class VFXSingleImpactComponent extends VFXComponent {

  /** @override */
  static TYPE = "singleImpact";

  /** @inheritDoc */
  static defineSchema() {
    const SL = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS;
    return {
      ...super.defineSchema(),
      position: new VFXReferenceObjectField(new SchemaField({
        x: new NumberField({required: true, nullable: false}),
        y: new NumberField({required: true, nullable: false}),
        elevation: new NumberField({required: true, nullable: false, initial: 0}),
        sort: new NumberField({nullable: false, initial: 0}),
        sortLayer: new NumberField({nullable: false, initial: SL.TOKENS})
      })),
      texture: new StringField({required: true}),
      duration: new NumberField({required: true, nullable: false, initial: 1000}),
      scale: new VFXReferencePointField({required: false}),
      size: new VFXReferenceField(new NumberField({required: false})),
      sound: new SchemaField({
        src: new StringField({required: true, blank: false}),
        align: new NumberField({required: true, nullable: false, choices: Object.values(SOUND_ALIGNMENT),
          initial: SOUND_ALIGNMENT.END}),
        volume: new AlphaField(),
        radius: new NumberField({required: true, nullable: false, initial: 60, positive: true}),
        easing: new BooleanField({initial: true}),
        walls: new BooleanField({initial: true})
      }, {nullable: true, initial: null}),
      animations: new ArrayField(new SchemaField({
        function: new StringField({required: true}),
        params: new VFXReferenceField(new ObjectField({required: false}))
      }))
    };
  }

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

  /**
   * Animation state used throughout.
   * Populated at the beginning of playback.
   * @type {object}
   */
  #state;

  /**
   * The Sound object used by this component.
   * @type {Sound|null}
   */
  #sound = null;

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

  /** @inheritDoc */
  async _load() {
    if ( this.texture ) this.assetPaths.add(this.texture);
    const soundPromises = [];
    if ( this.sound?.src ) soundPromises.push(
      foundry.audio.AudioHelper.preloadSound(this.sound.src).then(s => { this.#sound = s; })
    );
    await Promise.allSettled(soundPromises);
  }

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

  /** @override */
  async _draw() {

    // Create container if a texture is configured
    let container;
    if ( this.texture ) {
      container = this.#createContainer(this.position);
      this.addManagedDisplayObject(container);
    }

    // Construct animation state
    this.#state = {position: this.position, container};

    // Resolve custom animations and call animation setup functions
    for ( const animation of this.animations ) {
      try {
        Object.assign(animation, resolveAnimation(animation.function));
      } catch(err) {
        animation.animate = () => {}; // No-op
        console.warn(`Failed to resolve animation function "${animation.function}" in VFXSingleImpactComponent.`
          + " This animation will be skipped.");
      }
      animation.setup?.(this.#state, animation.params);
    }

    // Animate impact sprite
    const fadeMS = Math.min(this.duration / 8, 100);
    if ( container ) {
      this.timeline
        .add(container, {alpha: {from: 0, to: 1, duration: fadeMS}}, 0)
        .add(container, {alpha: {from: 1, to: 0, duration: fadeMS}}, this.duration - fadeMS);
    }

    // Add custom animations
    for ( const {animate, params} of this.animations ) {
      const progress = {t: 0};
      this.timeline.add(progress, {
        t: {from: 0, to: 1},
        duration: this.duration,
        ease: params.ease ?? "linear",
        onRender: () => animate(progress.t, this.#state, params)
      }, 0);
    }

    // Animate sound
    if ( this.#sound ) {
      const soundOffset = this.#alignSound(this.#sound);
      this.timeline.label("sound", soundOffset)
        .call(() => this.#playSound(this.position), soundOffset);
    }

    this.timeline.label("end", this.duration);
  }

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

  /** @override */
  async _stop() {
    if ( !this.#state ) return; // We never started

    // Call animation tearDown functions
    for ( const {tearDown, params} of this.animations ) tearDown?.(this.#state, params);

    // Stop sound playback
    const soundStops = [];
    if ( this.#sound?.playing ) soundStops.push(this.#sound.stop());
    await Promise.allSettled(soundStops);
  }

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

  /**
   * Play the positional sound for this impact.
   * Failures to play are tolerated with a warning so as not to break the overall animation.
   * @param {VFXBasePathPoint} position
   * @returns {Promise<void>}
   */
  async #playSound(position) {
    const {radius, volume, easing, walls} = this.sound;
    try {
      await this.#sound.playAtPosition(position, radius, {volume, easing, walls, gmAlways: true});
    } catch(err) {
      console.warn(err);
    }
  }

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

  /**
   * Create a VFXCanvasContainer and PrimarySpriteMesh for this impact.
   * @param {VFXBasePathPoint} point
   * @returns {VFXCanvasContainer}
   */
  #createContainer(point) {

    // Create container
    const container = new VFXCanvasContainer();
    container.position.set(point.x, point.y);
    container.elevation = point.elevation;
    container.sort = point.sort;
    container.sortLayer = point.sortLayer;
    container.alpha = 0; // Best practice

    // Create sprite mesh
    const texture = foundry.canvas.getTexture(this.texture);
    if ( !texture ) return container;
    const mesh = new foundry.canvas.primary.PrimarySpriteMesh(texture);
    mesh.name = "mesh";
    mesh.anchor.set(0.5, 0.5);

    // Assign size
    if ( Number.isNumeric(this.size) ) {
      if ( mesh.width >= mesh.height ) {
        mesh.width = this.size * canvas.dimensions.distancePixels;
        mesh.scale.y = mesh.scale.x;
      } else {
        mesh.height = this.size * canvas.dimensions.distancePixels;
        mesh.scale.x = mesh.scale.y;
      }
    }
    else if ( this.scale ) mesh.scale.copyFrom(this.scale);
    container.addChild(mesh);
    return container;
  }

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

  /**
   * Identify when a Sound should start playing relative to the component duration.
   * @param {Sound} sound     The Sound that should play
   * @returns {number}        Milliseconds relative to the component start when the sound should play
   */
  #alignSound(sound) {
    const soundMS = sound.duration * 1000;
    if ( soundMS >= this.duration ) return 0;
    switch ( this.sound.align ) {
      case SOUND_ALIGNMENT.END_START:
        return -soundMS;
      case SOUND_ALIGNMENT.START:
        return 0;
      case SOUND_ALIGNMENT.MIDDLE:
        return Math.max((this.duration - soundMS) / 2, 0);
      case SOUND_ALIGNMENT.END:
        return Math.max(this.duration - soundMS, 0);
      case SOUND_ALIGNMENT.START_END:
        return this.duration;
      default:
        return 0;
    }
  }
}
