import CanvasAnimation from './canvas-animation.mjs';
import SmoothNoise from './smooth-noise.mjs';
import MersenneTwister from '../../dice/twister.mjs';

/**
 * A lightweight screen/object shake utility.
 *
 * CanvasShakeEffect applies a smooth, time-based positional jitter to a target {@link PIXI.DisplayObject}
 * by offsetting its `x` and `y` coordinates relative to a captured reference point. The shake motion is produced using
 * two independent {@link foundry.canvas.animation.SmoothNoise} generators (one per axis) to avoid harsh,
 * frame-to-frame randomness and provide a more natural camera-like shake.
 *
 * The effect runs for {@link CanvasShakeEffect#duration} milliseconds, with a linearly decaying amplitude from
 * {@link CanvasShakeEffect#maxDisplacement} down to zero. After the active shake window ends, the target smoothly returns
 * to its reference point using {@link CanvasShakeEffect#returnSpeed} as a per-tick interpolation factor.
 *
 * If multiple shake instances target the same object, the newest shake replaces the currently active one using a
 * smooth transition, and the target returns to its original pre-shake position once shaking completes.
 * Important: Changes of `x` and `y` from external sources outside of CanvasShakeEffect are taken into account.
 *
 * Safety/termination conditions:
 * - If the target object is destroyed, the effect stops immediately.
 * - If {@link Canvas#photosensitiveMode} is enabled, the effect stops immediately.
 * - If {@link CanvasShakeEffect#maxDisplacement} or {@link CanvasShakeEffect#duration} are zero,
 *   the effect stops immediately.
 *
 * @example
 * // Shake a target for 6 seconds with a 20px peak amplitude
 * const shake = new foundry.canvas.animation.CanvasShakeEffect({
 *   target: canvas.stage,
 *   duration: 6000,
 *   maxDisplacement: 20,
 *   smoothness: 0.6,
 *   returnSpeed: 0.15,
 *   invalidateMasks: true
 * });
 * await shake.play();
 *
 * @example
 * // Use a custom ticker and a deterministic time offset
 * const shake = new foundry.canvas.animation.CanvasShakeEffect({
 *   target: someContainer,
 *   seed: 12345,
 *   ticker: myTicker
 * });
 * shake.play();
 */
export default class CanvasShakeEffect {
  /**
   * Create a new CanvasShakeEffect
   * @param {object} options
   * @param {PIXI.DisplayObject} [options.target=null] The target PIXI display object to shake. Defaults to canvas.stage
   * @param {number} [options.duration=5000]           Total shake duration in MS.
   * @param {number} [options.maxDisplacement=35]      Maximum displacement in pixels.
   * @param {number} [options.smoothness=0.5]          Smoothness in the range [0, 1]. Higher is smoother.
   * @param {number} [options.returnSpeed=0.1]         "Return to origin" lerp factor per tick in the range [0, 1].
   * @param {boolean} [options.invalidateMasks=false]  Should hidden canvas group masks be invalidated each frame?
   * @param {number|null} [options.seed=null]          Optional seed used to derive a deterministic time offset.
   * @param {PIXI.Ticker|null} [options.ticker=null]   Optional PIXI ticker. Defaults to {@link foundry.canvas.animation.CanvasAnimation.ticker}.
   */
  constructor({target=null, duration=5000, maxDisplacement=35, smoothness=0.5, returnSpeed=0.1, invalidateMasks=false, seed=null, ticker=null}) {
    this.#target = target ?? canvas.stage;

    // Sanitize inputs
    this.duration = Math.max(0, duration);
    this.maxDisplacement = Math.max(0, maxDisplacement);
    this.smoothness = Math.clamp(smoothness, 0, 1);
    this.returnSpeed = Math.clamp(returnSpeed, 0, 1);

    // Ticker
    this.#ticker = ticker ?? CanvasAnimation.ticker;

    // Noise scale mapping
    const minScale = 0.03;
    const maxScale = 0.001;
    const noiseScale = Math.mix(minScale, maxScale, this.smoothness);

    // Random offset
    this.#seed = seed;
    if ( this.#seed !== null ) {
      const twister = new MersenneTwister(this.#seed);
      this.randomOffset = Math.floor(twister.random() * 999999);
    }
    else this.randomOffset = 0;

    // Reference point initialized
    this.#referencePoint = {x: this.#target.x, y: this.#target.y};

    // Hidden canvas group masks invalidation
    this.#invalidateMasks = invalidateMasks ?? false;

    // Smooth noise initialisation
    const amp = this.maxDisplacement * 2;
    this.#noiseX = new SmoothNoise({amplitude: amp, scale: noiseScale});
    this.#noiseY = new SmoothNoise({amplitude: amp, scale: noiseScale});
  }

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

  /**
   * Duration in ms of the smooth takeover transition when a new shake replaces an active shake.
   * @type {number}
   */
  static TAKEOVER_DURATION_MS = 150;

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

  /**
   * The unique shake state symbol. Stored as a non-enumerable property on the target.
   * @type {symbol}
   */
  static #SHAKE_STATE = Symbol.for("foundry.canvas.animation.CanvasShakeEffect");

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

  /**
   * Total shake duration in MS. After this duration elapses, the effect transitions into a return-to-origin phase.
   * @type {number}
   */
  duration;

  /**
   * Maximum displacement in pixels during the shake. This value is used as the target maximum offset along each axis.
   * @type {number}
   */
  maxDisplacement;

  /**
   * Smoothness parameter in the range [0, 1]. Higher values produce smoother, lower-frequency motion.
   * @type {number}
   */
  smoothness;

  /**
   * Return-to-origin interpolation factor per tick, in the range [0, 1].
   * Higher values restore the target to its reference point more quickly.
   * @type {number}
   */
  returnSpeed;

  /**
   * The deterministic time offset derived from {@link CanvasShakeEffect#seed}.
   * Applied to the elapsed time before generating noise.
   * @type {number}
   */
  randomOffset;

  /**
   * Whether the shake effect is currently active.
   * @type {boolean}
   * @readonly
   */
  get playing() {
    return this.#playing;
  }
  #playing = false;

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

  /**
   * A promise resolve function for the active {@link CanvasShakeEffect#play} call.
   * This is cleared after the promise is resolved to ensure it fires only once.
   * @type {Function|null}
   */
  #resolve = null;

  /**
   * The promise returned by the most recent {@link CanvasShakeEffect#play} call.
   * When the effect is already playing, subsequent calls can return this same promise.
   * @type {Promise<void>|null}
   */
  #promise = null;

  /**
   * The ticker used to drive the effect.
   * Defaults to {@link foundry.canvas.animation.CanvasAnimation.ticker} if not provided.
   * @type {PIXI.Ticker}
   */
  #ticker;

  /**
   * The target PIXI display object to apply the shake effect to.
   * @type {PIXI.DisplayObject}
   */
  #target;

  /**
   * Optional random seed used to derive a deterministic time offset.
   * @type {number|null}
   */
  #seed;

  /**
   * The reference position captured for the target. The shake offsets are applied relative to this point.
   * @type {{x: number, y: number}}
   */
  #referencePoint;

  /**
   * The SmoothNoise generator used for horizontal displacement.
   * @type {foundry.canvas.animation.SmoothNoise}
   */
  #noiseX;

  /**
   * The SmoothNoise generator used for vertical displacement.
   * @type {foundry.canvas.animation.SmoothNoise}
   */
  #noiseY;

  /**
   * Whether hidden canvas group masks should be invalidated each frame.
   * @type {boolean}
   */
  #invalidateMasks = false;

  /* -------------------------------------------- */
  /* Public API                                   */
  /* -------------------------------------------- */

  /**
   * Start the shake effect.
   * Registers a ticker callback and returns a promise that resolves once the effect ends.
   * If the effect is already playing, returns the existing promise (or a resolved one as a fallback).
   * @returns {Promise<void>} A promise that resolves when the effect completes or is stopped.
   */
  play() {
    if ( this.#playing ) return this.#promise ?? Promise.resolve();

    const state = CanvasShakeEffect.#getShakeState(this.#target);
    const active = state.active;
    const replaced = active && (active !== this) && active.playing;

    // If another shake is active on this target, replace it smoothly
    if ( replaced ) {
      state.blendFromX = state.offX;
      state.blendFromY = state.offY;
      state.blendStartMS = this.#ticker.lastTime;
      state.blendDurationMS = CanvasShakeEffect.TAKEOVER_DURATION_MS;
      active.stop({snap: false, release: false});
    }

    // If no shake was active, capture the original base position
    else {
      state.baseX = this.#target.x;
      state.baseY = this.#target.y;
      state.offX = 0;
      state.offY = 0;
      state.blendDurationMS = 0;
    }

    this.#promise = new Promise((resolve) => {
      if ( this.#target.destroyed ) return resolve();

      this.#playing = true;
      this.#resolve = resolve;

      // Become the active shake for this target
      state.active = this;

      // Capture reference (the base position before any shake)
      this.#referencePoint.x = state.baseX;
      this.#referencePoint.y = state.baseY;

      this.startTime = this.#ticker.lastTime;
      this.#ticker.add(this.#onTick, this);
    });

    return this.#promise;
  }

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

  /**
   * Stop the shake effect immediately. Removes the ticker callback, optionally snaps the target back to its base
   * position, and resolves the active promise.
   * @param {object} [options]
   * @param {boolean} [options.snap=true]     Snap the target back to its base position.
   * @param {boolean} [options.release=true]  Release the target shake state if no other shake is active.
   */
  stop({snap=true, release=true}={}) {
    if ( !this.#playing ) return;

    const state = this.#target[CanvasShakeEffect.#SHAKE_STATE];

    if ( snap && !this.#target.destroyed ) {
      if ( state ) {
        const EPS = 1e-4;
        const expectedX = state.baseX + state.offX;
        const expectedY = state.baseY + state.offY;

        // If the target no longer matches our expected position, assume an external system has moved it
        // Rebase to the current position and clear any pending offsets/blend
        if ( (Math.abs(this.#target.x - expectedX) > EPS) || (Math.abs(this.#target.y - expectedY) > EPS) ) {
          state.baseX = this.#target.x;
          state.baseY = this.#target.y;
        }

        // Otherwise, remove the last applied offset to recover the base
        else {
          this.#target.x -= state.offX;
          this.#target.y -= state.offY;
          state.baseX = this.#target.x;
          state.baseY = this.#target.y;
        }

        state.offX = 0;
        state.offY = 0;
        state.blendDurationMS = 0;
      }
      else {
        this.#target.x = this.#referencePoint.x;
        this.#target.y = this.#referencePoint.y;
      }
    }

    this.#ticker.remove(this.#onTick, this);
    this.#playing = false;

    if ( state?.active === this ) state.active = null;
    if ( release && state ) CanvasShakeEffect.#clearShakeState(this.#target, state);
    if ( this.#invalidateMasks ) canvas.hidden.invalidateMasks();

    if ( this.#resolve ) {
      this.#resolve();
      this.#resolve = null;
    }
  }

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

  /**
   * Ticker update callback. Applies shake offsets during the active phase, then smoothly returns the target to its
   * base position once the duration elapses.
   */
  #onTick() {
    if ( this.#target.destroyed ) return this.stop({snap: false});

    // If intensity is zero, short-circuit safely (and disable for photosensitive mode)
    if ( canvas.photosensitiveMode || this.maxDisplacement <= 0 || this.duration <= 0 ) return this.stop();

    const state = this.#target[CanvasShakeEffect.#SHAKE_STATE];
    if ( !state || (state.active !== this) ) return this.stop({snap: false});

    // Follow the target base position if something else moves the target during the shake
    // If the target no longer matches our expected position, treat its current position as the new base
    const EPS = 1e-4;
    const expectedX = state.baseX + state.offX;
    const expectedY = state.baseY + state.offY;

    if ( (Math.abs(this.#target.x - expectedX) > EPS) || (Math.abs(this.#target.y - expectedY) > EPS) ) {
      state.baseX = this.#target.x;
      state.baseY = this.#target.y;
      state.offX = 0;
      state.offY = 0;
      state.blendDurationMS = 0;
    }
    else {
      state.baseX = this.#target.x - state.offX;
      state.baseY = this.#target.y - state.offY;
    }

    this.#referencePoint.x = state.baseX;
    this.#referencePoint.y = state.baseY;

    const now = this.#ticker.lastTime;
    const elapsed = now - this.startTime;
    const durationMS = this.duration;

    // After duration: return to base position
    if ( elapsed >= durationMS ) {

      // If returnSpeed is 0, snap instantly
      if ( this.returnSpeed <= 0 ) return this.stop();

      const offsetX = Math.mix(state.offX, 0, this.returnSpeed);
      const offsetY = Math.mix(state.offY, 0, this.returnSpeed);
      this.#target.x = this.#referencePoint.x + offsetX;
      this.#target.y = this.#referencePoint.y + offsetY;
      state.offX = offsetX;
      state.offY = offsetY;

      const done = Math.abs(state.offX) < 0.1 && Math.abs(state.offY) < 0.1;
      if ( done ) this.stop();
      return;
    }

    // Shake phase
    const noiseElapsed = elapsed + this.randomOffset;

    // Current amplitude in pixels
    const intensity = Math.mix(0, this.maxDisplacement, 1 - elapsed / durationMS);

    // Generate smooth noise for vertical and horizontal displacements
    const rawNoiseX = this.#noiseX.generate(noiseElapsed);
    const rawNoiseY = this.#noiseY.generate(noiseElapsed);
    const baseX = rawNoiseX - this.maxDisplacement;
    const baseY = rawNoiseY - this.maxDisplacement;
    let offsetX = baseX * (intensity / this.maxDisplacement);
    let offsetY = baseY * (intensity / this.maxDisplacement);

    // Smooth blend
    if ( state.blendDurationMS > 0 ) {
      const bt = (now - state.blendStartMS) / state.blendDurationMS;
      if ( bt >= 1 ) state.blendDurationMS = 0;
      else {
        const t = bt * bt * (3 - 2 * bt);
        offsetX = Math.mix(state.blendFromX, offsetX, t);
        offsetY = Math.mix(state.blendFromY, offsetY, t);
      }
    }

    this.#target.x = this.#referencePoint.x + offsetX;
    this.#target.y = this.#referencePoint.y + offsetY;
    state.offX = offsetX;
    state.offY = offsetY;

    if ( this.#invalidateMasks ) canvas.hidden.invalidateMasks();
  }

  /* -------------------------------------------- */
  /* Static Private Methods                       */
  /* -------------------------------------------- */

  /**
   * Get or create the shared shake state for a target.
   * @param {PIXI.DisplayObject} target
   * @returns {object}
   */
  static #getShakeState(target) {
    let state = target[this.#SHAKE_STATE];
    if ( state ) return state;

    state = {
      active: null,
      baseX: target.x,
      baseY: target.y,
      offX: 0,
      offY: 0,
      blendFromX: 0,
      blendFromY: 0,
      blendStartMS: 0,
      blendDurationMS: 0
    };

    Object.defineProperty(target, this.#SHAKE_STATE, {
      value: state,
      configurable: true,
      enumerable: false
    });

    return state;
  }

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

  /**
   * Clear the shared shake state from a target if no shake remains active.
   * @param {PIXI.DisplayObject} target
   * @param {object} state
   */
  static #clearShakeState(target, state) {
    if ( state?.active ) return;
    delete target[this.#SHAKE_STATE];
  }
}
