import UnboundContainer from "./containers/advanced/unbound-container.mjs";
import TextureTransitionFilter from "./rendering/filters/transition.mjs";
import CanvasAnimation from "./animation/canvas-animation.mjs";

/**
 * Persistent overlay dedicated to scene transitions.
 */
export default class TransitionContainer extends UnboundContainer {
  constructor() {
    super();
    this.visible = false;
    this.zIndex = +Infinity;
    this.eventMode = "none";
  }

  /**
   * CanvasAnimation symbol used for scene transition animations.
   * @type {symbol}
   */
  static #ANIMATION_SYMBOL = Symbol("TRANSITION_ANIMATION");

  /**
   * Desired transition type to use for transitions when no explicit type is provided.
   * @type {string}
   */
  defaultTransitionType = CONFIG.Canvas.sceneTransitions.fade.id;

  /**
   * Default transition duration used by {@link TransitionContainer#_play}
   * when no explicit duration is provided (milliseconds).
   * @type {number}
   */
  defaultDuration = 1000;

  /**
   * Flag indicating whether this container is reserved for an external workflow.
   * When true, core scene transitions should not use this container.
   * @type {boolean}
   */
  isLocked = false;

  /**
   * Render texture containing the source (current) scene frame.
   * Used as the "from" texture in the transition.
   * @type {PIXI.RenderTexture|null}
   */
  #fromRT = null;

  /**
   * Render texture containing the destination (next) scene frame.
   * Used as the "to" texture in the transition.
   * @type {PIXI.RenderTexture|null}
   */
  #toRT = null;

  /**
   * Sprite used to display the captured scene texture full-screen.
   * This sprite is the visual surface for the transition filter.
   * @type {PIXI.Sprite|null}
   */
  #sprite = null;

  /**
   * Active transition filter blending from the source to the destination texture.
   * Created lazily by {@link TransitionContainer#_captureNextScene}.
   * @type {TextureTransitionFilter|null}
   */
  #filter = null;

  /**
   * Handling of the concurrency for transition.
   * @type {Semaphore}
   */
  #queue = new foundry.utils.Semaphore();

  /**
   * Binded run function
   * @type {(function({operation?: Function, nextScene?: globalThis.Scene, activate?: boolean, clearColor?: number[], fromBlack?: boolean, duration?: number, transitionType?: string, easing?: Function}=): Promise<void>)|*}
   */
  #runBound = this.#run.bind(this);

  /* ----------------------------------------- */
  /*  Getters                                  */
  /* ----------------------------------------- */

  /**
   * Flag indicating whether a transition animation is currently running.
   * Prevents overlapping calls to {@link TransitionContainer#_play}.
   * @type {boolean}
   */
  get isRunning() {
    return this.#running;
  }
  #running = false;

  /**
   * Promise that resolves when the current transition finishes or is cancelled.
   * Reused to return the same promise on repeated {@link TransitionContainer#_play} calls.
   * @type {Promise<void>|null}
   */
  get promise() {
    return this.#promise;
  }
  #promise = null;

  /* ----------------------------------------- */
  /*  Capture                                  */
  /* ----------------------------------------- */

  /**
   * Snapshot the current framebuffer into a render texture.
   * The overlay container hides itself during capture so it is not included.
   * @param {number[]} [clearColor=[0,0,0,0]] RGBA clear color in the 0-1 range.
   * @returns {PIXI.RenderTexture} The newly created render texture.
   */
  static #capture(clearColor=[0,0,0,0]) {
    const renderer = canvas.app.renderer;
    const {width, height} = renderer.screen;
    const resolution = renderer.resolution;
    const multisample = renderer.multisample ?? PIXI.MSAA_QUALITY.NONE;

    // Hide the transition overlay so we do not capture a previous sprite.
    const overlay = canvas.transition;
    const wasVisible = overlay.visible;
    if ( wasVisible ) overlay.visible = false;

    const rt = foundry.canvas.Canvas.getRenderTexture({
      clearColor,
      textureConfiguration: {width, height, resolution, multisample}
    });

    const rootVisible = canvas.root.visible;
    canvas.root.visible = true;
    renderer.render(canvas.stage, {renderTexture: rt});
    canvas.root.visible = rootVisible;

    if ( wasVisible ) overlay.visible = true;
    return rt;
  }

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

  /**
   * Run a full transition around a given canvas operation.
   * Encapsulates captureCurrentScene => operation/scene switch => captureNextScene => play.
   *
   * If both {@link options.operation} and {@link options.nextScene} are provided, the operation runs first,
   * then the Scene is switched.
   *
   * If neither {@link options.operation} nor {@link options.nextScene} are provided, a simple demo transition
   * is performed from a black frame to the currently rendered Scene.
   *
   * @example Transition around a camera pan
   * ```js
   * await canvas.transition.run({
   *   operation: async () => {
   *     await canvas.animatePan({
   *       x: 2000,
   *       y: 1500,
   *       scale: 1.25,
   *       duration: 0
   *     });
   *   },
   *   duration: 800,
   *   transitionType: "dots"
   * });
   * ```
   *
   * @example Switch to another Scene with a transition
   * ```js
   * const scene = game.scenes.get("ABC123");
   * await canvas.transition.run({
   *   nextScene: scene,
   *   activate: true,
   *   duration: 1200,
   *   transitionType: "fade"
   * });
   * ```
   *
   * @example Demo the current Scene from black
   * ```js
   * await canvas.transition.run({
   *   fromBlack: true,
   *   duration: 800,
   *   transitionType: "swirl"
   * });
   * ```
   * @param {Function} [operation]              Async function performing canvas changes.
   * @param {Scene}    [nextScene]              Scene document to view or activate as the "next" scene.
   * @param {boolean}  [activate=false]         When true, call {@link Scene#activate} instead of {@link Scene#view}.
   * @param {number[]} [clearColor=[0,0,0,1]]   RGBA clear color in the 0-1 range.
   * @param {boolean}  [fromBlack=false]        When true, starts from a black frame instead of capturing.
   * @param {number}   [duration]               Duration in milliseconds. Defaults to {@link defaultDuration}.
   * @param {string}   [transitionType]         Transition type id to use for this run.
   * @param {Function} [easing=t=>t]            Easing function mapping [0,1] to [0,1].
   * @returns {Promise<void>} Promise that resolves when the transition completes.
   */
  async run({operation, nextScene, activate=false, clearColor=[0,0,0,1], fromBlack=false, duration, transitionType, easing=t=>t}={}) {
    return this.#queue.add(this.#runBound, {operation, nextScene, activate, clearColor, fromBlack, duration, transitionType, easing});
  }

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

  /**
   * Cancel any currently running transition and await its termination.
   * @returns {Promise<void>}
   */
  async cancel() {
    if ( !this.#running ) return;
    const p = this.#promise;
    this._reset();
    if ( p ) await p.catch(() => {});
  }

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

  /**
   * Internal implementation of {@link TransitionContainer#run}.
   * @param {Function} [operation]              Async function performing canvas changes.
   * @param {Scene}    [nextScene]              Scene document to view or activate as the "next" scene.
   * @param {boolean}  [activate=false]         When true, call {@link Scene#activate} instead of {@link Scene#view}.
   * @param {number[]} [clearColor=[0,0,0,1]]   RGBA clear color in the 0-1 range.
   * @param {boolean}  [fromBlack=false]        When true, starts from a black frame instead of capturing.
   * @param {number}   [duration]               Duration in milliseconds. Defaults to {@link defaultDuration}.
   * @param {string}   [transitionType]         Transition type id to use for this run.
   * @param {Function} [easing=t=>t]            Easing function mapping [0,1] to [0,1].
   * @returns {Promise<void>} Promise that resolves when the transition completes.
   */
  async #run({operation, nextScene, activate=false, clearColor=[0,0,0,1], fromBlack=false, duration, transitionType, easing=t=>t}={}) {
    const hasOperation = typeof operation === "function";
    const hasNextScene = nextScene instanceof Scene;

    // If nothing to do between the two captures, treat this as a "demo" run
    const effectiveDuration = duration ?? this.defaultDuration;
    const effectiveType = transitionType ?? this.defaultTransitionType;

    // Capture current scene (or black) before doing any work
    this._captureCurrentScene({clearColor, black: fromBlack || (!hasOperation && !hasNextScene)});

    // Only lock while we perform operations that may trigger Canvas#draw
    const previousLock = this.isLocked;
    this.isLocked = true;
    try {
      // Custom operation
      if ( hasOperation ) {
        await operation();
      }

      // Next scene switch
      if ( hasNextScene ) {
        if ( activate ) await nextScene.activate();
        else await nextScene.view();
      }
    }
    finally {
      this.isLocked = previousLock;
    }

    // Capture next scene (which is: either modified current, or nextScene, or just the same scene)
    const wasVisible = canvas.root.visible;
    canvas.root.visible = false;
    try {
      await this._captureNextScene({clearColor, transitionType: effectiveType});
      await this._play({duration: effectiveDuration, easing});
    }
    catch(err) {
      // If anything goes wrong, do not block the scene load
      console.error(`Scene transition failed (type="${effectiveType}"). Continuing without transition.`, err);
      this._reset();
    }
    finally {
      canvas.root.visible = wasVisible;
    }
  }

  /* ----------------------------------------- */
  /*  Internals                                */
  /* ----------------------------------------- */

  /**
   * Capture the currently displayed scene into a render texture and show it.
   * If `black` is true, uses a solid black frame instead of capturing the scene.
   * @param {object}   [options]                       Capture options.
   * @param {number[]} [options.clearColor=[0,0,0,1]]  RGBA clear color in the 0-1 range.
   * @param {boolean}  [options.black=false]           When true, uses a black frame instead of capturing.
   * @returns {PIXI.RenderTexture|null} The render texture of the current scene, or null if black.
   * @internal
   */
  _captureCurrentScene({clearColor=[0, 0, 0, 1], black=false}={}) {
    this._reset();

    // Solid black frame instead of capturing.
    if ( black ) {
      const texture = PIXI.Texture.WHITE;
      this.#sprite = this.#createSprite(texture, 1, 0x000000);
      this.addChild(this.#sprite);
      this.visible = true;
      return null;
    }

    this.#fromRT = TransitionContainer.#capture(clearColor);
    const texture = this.#fromRT;
    this.#sprite = this.#createSprite(texture, 1);
    this.addChild(this.#sprite);
    this.visible = true;
    return this.#fromRT;
  }

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

  /**
   * Capture the next rendered frame of the new scene into a render texture.
   * Internally waits for the next `postrender` so all canvas groups and caches are fully updated before capturing.
   * The filter class and filter type are resolved from {@link CONFIG.Canvas.sceneTransitions}.
   *
   * @param {object}   [options]                       Capture options.
   * @param {number[]} [options.clearColor=[0,0,0,1]]  RGBA clear color in the 0-1 range.
   * @param {string}   [options.transitionType]        Transition type id to use for this capture.
   *                                                   Defaults to {@link TransitionContainer#defaultTransitionType}.
   * @returns {Promise<PIXI.RenderTexture>}            Promise resolving to the captured render texture.
   * @internal
   */
  async _captureNextScene({clearColor=[0, 0, 0, 1], transitionType}={}) {
    const {filterClass, filterType} = this.#resolveTransition(transitionType);

    return new Promise(resolve => {
      const renderer = canvas.app.renderer;

      const after = () => {
        // Ignore render operations that are not for the canvas stage
        const last = renderer.lastObjectRendered;
        if ( last !== canvas.stage ) return;

        renderer.off("postrender", after);

        this.#toRT = TransitionContainer.#capture(clearColor);

        // If the sprite was reset in the meantime, we cannot attach a filter.
        if ( !this.#sprite ) {
          resolve(this.#toRT);
          return;
        }

        // Ensure the filter instance matches the configured filter class.
        if ( this.#filter && !(this.#filter instanceof filterClass) ) {
          this.#filter.destroy?.(true);
          this.#filter = null;
        }
        if ( !this.#filter ) this.#filter = filterClass.create();

        this.#filter.type = filterType;
        this.#filter.targetTexture = this.#toRT;
        this.#filter.uniforms.progress = 0;
        this.#filter.uniforms.opaque = 1;
        this.#filter.uniforms.backgroundColor = clearColor;

        this.#sprite.filters = [this.#filter];
        this.visible = true;

        resolve(this.#toRT);
      };

      renderer.on("postrender", after);
    });
  }

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

  /**
   * Run the transition animation from the captured "from" texture to the "to" texture.
   * If a transition is already running, returns the existing promise.
   * If no filter has been prepared, resolves immediately.
   * @param {object}   [opts]                               Animation options.
   * @param {number}   [opts.duration=this.defaultDuration] Duration in milliseconds.
   * @param {Function} [opts.easing=t=>t]                   Easing function mapping [0,1] to [0,1].
   * @returns {Promise<void>} Promise that resolves when the transition completes.
   */
  _play({duration=this.defaultDuration, easing=t=>t}={}) {
    const existing = CanvasAnimation.getAnimation(this.constructor.#ANIMATION_SYMBOL);
    if ( !existing && this.#running ) {
      this.#running = false;
      this.#promise = null;
    }

    if ( this.#running ) return this.#promise ?? Promise.resolve();
    if ( !this.#filter ) return Promise.resolve();

    this.#running = true;
    this.visible = true;

    const easingFn = (typeof easing === "function") ? easing : (t => t);

    const uniforms = this.#filter.uniforms;
    uniforms.progress = 0;

    const attributes = [{parent: uniforms, attribute: "progress", to: 1}];
    const animationOptions = {
      name: this.constructor.#ANIMATION_SYMBOL,
      duration,
      easing: easingFn
    };
    return this.#promise = CanvasAnimation.animate(attributes, animationOptions).finally(() => this.#cleanup());
  }

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

  /**
   * Create a full-screen sprite for the given texture.
   * @param {PIXI.Texture|PIXI.RenderTexture} texture Texture to display.
   * @param {number} [alpha=1]                        Sprite alpha value.
   * @param {number} [tint=0xFFFFFF]                  Sprite tint color.
   * @returns {PIXI.Sprite} Configured full-screen sprite.
   */
  #createSprite(texture, alpha=1, tint=0xFFFFFF) {
    const {width, height} = canvas.app.renderer.screen;
    return Object.assign(new PIXI.Sprite(texture), {width, height, alpha, tint});
  }

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

  /**
   * Resolve a transition definition from CONFIG with a safe fallback.
   * If the requested type does not exist (per example a module removed), fall back to fade.
   * @param {string|null|undefined} transitionType
   * @returns {{filterClass: typeof TextureTransitionFilter, filterType: string, id: string}}
   */
  #resolveTransition(transitionType) {
    const requested = transitionType ?? this.defaultTransitionType;

    // Prefer requested if present
    const def = CONFIG.Canvas.sceneTransitions?.[requested];
    if ( def ) {
      return {
        id: requested,
        filterClass: def.filterClass ?? TextureTransitionFilter,
        filterType: def.filterType ?? requested
      };
    }

    // Fallback
    const fallbackId =
      (CONFIG.Canvas.sceneTransitions?.[this.defaultTransitionType] ? this.defaultTransitionType : null)
      ?? (CONFIG.Canvas.sceneTransitions?.fade?.id ? CONFIG.Canvas.sceneTransitions.fade.id : null)
      ?? (Object.keys(CONFIG.Canvas.sceneTransitions ?? {})[0] ?? "fade");
    const fb = CONFIG.Canvas.sceneTransitions?.[fallbackId];
    console.warn(`Transition type "${requested}" was not found in CONFIG.Canvas.sceneTransitions. Falling back to "${fallbackId}".`);

    return {
      id: fallbackId,
      filterClass: fb?.filterClass ?? TextureTransitionFilter,
      filterType: fb?.filterType ?? fallbackId
    };
  }

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

  /**
   * Internal cleanup and processing when the animation finishes or is terminated.
   * Hides the overlay, removes filters, destroys textures, invalidate masks, and clears flags.
   */
  #cleanup() {
    canvas.hidden.invalidateMasks();

    if ( this.#sprite ) {
      if ( this.#toRT ) this.#sprite.texture = this.#toRT;
      this.#sprite.filters = null;
    }

    this.visible = false;
    this.removeChildren();

    this.#sprite = null;
    this.#filter = null;

    this.#fromRT?.destroy(true);
    this.#toRT?.destroy(true);
    this.#fromRT = this.#toRT = null;

    this.#running = false;
    this.#promise = null;
  }

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

  /**
   * Reset the internal state of the transition container.
   */
  _reset() {
    CanvasAnimation.terminateAnimation(this.constructor.#ANIMATION_SYMBOL);
    this.#cleanup();
  }
}
