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 VFXPath from "../vfx-path.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 {iterateValues} from "@common/utils/helpers.mjs";

/**
 * @import {DataSchema} from "@common/abstract/_types.mjs";
 * @import {VFXBasePathPoint, VFXComponentAnimation, VFXComponentData, VFXPositionalSoundData} from "../_types.mjs";
 */

/** @typedef {"charge"|"projectile"|"impact"} VFXSingleAttackStepName */

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

/**
 * @typedef VFXSingleAttackPathTypeData
 * @property {string} type      The path type registered in CONFIG.Canvas.vfx.paths.
 * @property {object} [params]  Additional parameters passed to the path generator.
 */

/**
 * @typedef _VFXSingleAttackData
 * @property {VFXBasePathPoint[]} path
 * @property {VFXSingleAttackPathTypeData} pathType
 * @property {VFXSingleAttackStep} charge
 * @property {VFXSingleAttackStep & {speed?: number}} projectile
 * @property {VFXSingleAttackStep} impact
 */

/**
 * @typedef {VFXComponentData & _VFXSingleAttackData} VFXSingleAttackData
 */

/**
 * A base class VFX component used for single actor -> target direct attacks.
 *
 * This provides a convenience layer that can include any or all of the following:
 * 1. Pre-attack charge-up sprite animation
 * 2. Pre-attack charge-up audio
 * 3. Attack projectile sprite
 * 4. Attack projectile audio
 * 5. Pre-impact audio
 * 6. Impact sprite animation
 * 7. Impact (or pre-impact) audio
 *
 * @example A projectile with an impact animation
 * ```js
 * const vfxConfig = {
 *   name: "arrowShot",
 *   components: {
 *     arrowShot: {
 *       type: "singleAttack",
 *       path: [{reference: "token", deltas: {sort: 1}}, {reference: "target", deltas: {sort: 1}}],
 *       charge: {
 *         duration: 1000,
 *         animations: [{function: "drawBack"}],
 *         sound: {
 *          src: "assets/sounds/BowAttack1.ogg",
 *          align: 2
 *         }
 *       },
 *       projectile: {
 *        texture: "assets/arrow/arrow-wood.png",
 *        animations: [{function: "followPath"}],
 *        size: 3, // feet
 *        speed: 150 // feet-per-second
 *       },
 *       impact: {
 *         texture: "assets/impact/BloodSplatter1.png",
 *         duration: 2000,
 *         sound: {
 *            src: "assets/sounds/ArrowHit1.wav",
 *            align: 1
 *         }
 *       }
 *     }
 *   },
 *   timeline: [{component: "arrowShot"}]
 * };
 * const effect = new foundry.canvas.vfx.VFXEffect(vfxConfig);
 * const target = game.user.targets.first();
 * effect.play({
 *   token: {..._token.center, elevation: _token.document.elevation, sort: _token.document.sort},
 *   target: {...target.center, elevation: target.document.elevation, sort: target.document.sort}
 * });
 * ```
 * @extends {VFXComponent<VFXSingleAttackData>}
 */
export default class VFXSingleAttackComponent extends VFXComponent {

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

  /**
   * The steps of the single attack sequence.
   * @type {VFXSingleAttackStepName[]}
   */
  static STEPS = ["charge", "projectile", "impact"];

  /**
   * The configured origin point of the path.
   * This is recorded at the beginning of the _draw workflow once references are resolved.
   * @type {VFXBasePathPoint}
   */
  origin;

  /**
   * The configured destination point of the path.
   * This is recorded at the beginning of the _draw workflow once references are resolved.
   * @type {VFXBasePathPoint}
   */
  destination;

  /**
   * The configured VFXPath that the flight of the projectile follows.
   * This is computed at the beginning of the _draw workflow once references are resolved.
   * @type {VFXPath}
   */
  #flightPath;

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

  /**
   * Sound objects that are used as part of this component.
   * @type {Record<VFXSingleAttackStepName, Sound|null>}
   */
  #sounds = this.constructor.STEPS.reduce((obj, s) => {
    obj[s] = null;
    return obj;
  }, {});

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

  /** @inheritDoc */
  static defineSchema() {
    const SL = foundry.canvas.groups.PrimaryCanvasGroup.SORT_LAYERS;
    return {
      ...super.defineSchema(),
      path: new ArrayField(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})
      })), {required: true, min: 2}),
      pathType: new SchemaField({
        type: new StringField({required: true, initial: "linear"}),
        params: new ObjectField({required: false})
      }),
      charge: new SchemaField(VFXSingleAttackComponent.#attackStepSchema()),
      projectile: new SchemaField({
        ...VFXSingleAttackComponent.#attackStepSchema(),
        speed: new NumberField({required: false})
      }),
      impact: new SchemaField(VFXSingleAttackComponent.#attackStepSchema())
    };
  }

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

  /**
   * A reused sub-schema definition shared across all three of the charge, projectile, and impact steps.
   * @returns {DataSchema}
   */
  static #attackStepSchema() {
    return {
      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})),
      animations: new ArrayField(new SchemaField({
        function: new StringField({required: true}),
        params: new VFXReferenceField(new ObjectField())
      })),
      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})
    };
  }

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

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

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

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

    // Initialize positions and duration
    this.origin = this.path[0];
    this.destination = this.path.at(-1);

    // Generate flight path using configured path type
    const pathType = this.pathType.type ?? "linear";
    this.#flightPath = VFXPath.create(pathType, this.path, this.pathType.params);

    // Construct animation state
    this.#state = {
      charge: this.charge,
      projectile: this.projectile,
      impact: this.impact,
      origin: this.origin,
      destination: this.destination,
      flightPath: this.#flightPath
    };

    // Customize projectile speed according to distance
    if ( this.projectile.speed ) {
      const speed = this.projectile.speed * canvas.dimensions.distancePixels;
      this.projectile.duration = this.#flightPath.pathLength * 1000 / speed;
    }

    // Create VFXContainer instances
    if ( this.charge.texture ) {
      this.charge.container = this.addManagedDisplayObject(this.#createContainer(this.charge, this.origin));
    }
    if ( this.projectile.texture ) {
      this.projectile.container = this.addManagedDisplayObject(this.#createContainer(this.projectile, this.origin));
    }
    if ( this.impact.texture ) {
      this.impact.container = this.addManagedDisplayObject(this.#createContainer(this.impact, this.destination));
    }

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

    // Define timeline and animations
    const timings = this._getTimings();
    this._animateCharge(timings);
    this._animateProjectile(timings);
    this._animateImpact(timings);
    this.timeline.label("end", timings.impactEnd);
  }

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

  /**
   * Basic charge animation. It is expected for subclasses to override this to refine the effect.
   * @param {Record<string, number>} timings
   * @protected
   */
  _animateCharge(timings) {
    const c = this.charge;
    const p = this.projectile;
    const fadeMS = Math.min(c.duration / 8, 100);
    this.timeline.label("chargeStart", timings.chargeStart).label("chargeEnd", timings.chargeEnd);

    // Animate charge container
    if ( c.container ) {
      this.timeline.add(c.container, {alpha: {from: 0, to: 1, duration: fadeMS}}, timings.chargeStart)
        .add(c.container, {alpha: {from: 1, to: 0, duration: fadeMS}}, timings.chargeEnd - fadeMS);
    }

    // Animate projectile during charge
    if ( p.container ) {
      this.timeline.set(p.container, {rotation: this.#flightPath.pathPoints[0].rotation}, timings.chargeStart);
    }

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

    // Animate sound
    if ( this.#sounds.charge ) {
      this.timeline.label("chargeSound", timings.chargeSound)
        .call(() => this.#playSound(this.#sounds.charge, c, this.origin), timings.chargeSound);
    }
  }

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

  /**
   * Basic projectile animation. It is expected for subclasses to override this to refine the effect.
   * @param {Record<string, number>} timings
   * @protected
   */
  _animateProjectile(timings) {
    const p = this.projectile;
    this.timeline.label("projectileStart", timings.projectileStart).label("projectileEnd", timings.projectileEnd);

    // Animate sprite
    if ( p.container ) this.timeline.set(p.container, {alpha: 1, duration: 0}, timings.projectileStart);

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

    // Animate sound
    if ( this.#sounds.projectile ) {
      this.timeline.label("projectileSound", timings.projectileSound)
        .call(() => this.#playSound(this.#sounds.projectile, p, this.origin), timings.projectileSound);
    }
  }

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

  /**
   * Basic impact animation. It is expected for subclasses to override this to refine the effect.
   * @param {Record<string, number>} timings
   * @protected
   */
  _animateImpact(timings) {
    const i = this.impact;
    const p = this.projectile;
    const fadeMS = Math.min(i.duration / 8, 100);
    this.timeline.label("impactStart", timings.impactStart).label("impactEnd", timings.impactEnd);

    // Animate impact sprite
    if ( i.container ) {
      this.timeline.add(i.container, {alpha: {from: 0, to: 1, duration: fadeMS}}, timings.impactStart)
        .add(i.container, {alpha: {from: 1, to: 0, duration: fadeMS}}, timings.impactEnd - fadeMS);
    }

    // Animate projectile sprite
    if ( p.container ) {
      const fadeOutMS = Math.min(i.duration / 4, 100);
      this.timeline.add(p.container, {alpha: 0, duration: fadeOutMS}, timings.impactEnd - fadeOutMS);
    }

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

    // Animate sound
    if ( this.#sounds.impact ) {
      this.timeline.label("impactSound", timings.impactSound)
        .call(() => this.#playSound(this.#sounds.impact, i, this.destination), timings.impactSound);
    }
  }

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

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

    // Call animation tearDown functions
    for (const stepName of this.constructor.STEPS) {
      const step = this[stepName];
      for ( const {tearDown, params} of step.animations ) tearDown?.(this.#state, params);
    }

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

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

  /** @inheritDoc */
  _destroy() {
    super._destroy();
    delete this.charge.container;
    delete this.projectile.container;
    delete this.impact.container;
  }

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

  /**
   * Play a sound effect for the impact.
   * Failures to play a sound are tolerated with a warning such as not to break the overall animation.
   * @param {Sound|null} sound
   * @param {VFXSingleAttackStep} step
   * @param {VFXPathPoint} position
   * @returns {Promise<void>}
   */
  async #playSound(sound, step, position) {
    if ( !sound ) return;
    const {radius, volume, easing, walls} = step.sound;
    try {
      await sound.playAtPosition(position, radius, {volume, easing, walls, gmAlways: true});
    } catch(err) {
      console.warn(err);
    }
  }

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

  /**
   * Create a VFXContainer and included PrimaryCanvasSpriteMesh used for a step of the attack.
   * @param {VFXSingleAttackStep} step
   * @param {VFXPathPoint} point
   * @returns {VFXCanvasContainer}
   */
  #createContainer(step, 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(step.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(step.size) ) {
      if ( mesh.width >= mesh.height ) {
        mesh.width = step.size * canvas.dimensions.distancePixels;
        mesh.scale.y = mesh.scale.x;
      } else {
        mesh.height = step.size * canvas.dimensions.distancePixels;
        mesh.scale.x = mesh.scale.y;
      }
    }
    else if ( step.scale ) mesh.scale.copyFrom(step.scale);
    container.addChild(mesh);
    return container;
  }

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

  /**
   * Compute timings for each step start, end, and sound.
   * @returns {Record<string, number>}
   * @protected
   */
  _getTimings() {
    const timings = {};
    const sounds = this.#sounds;
    let end = 0;
    for ( const s of this.constructor.STEPS ) {
      const step = this[s];
      const start = timings[`${s}Start`] = end;
      end = timings[`${s}End`] = start + step.duration;
      if ( sounds[s] ) timings[`${s}Sound`] = start + this.#alignSound(sounds[s], step);
    }
    return timings;
  }

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

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