/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

this.MarqueeWidget = class {
  constructor(shadowRoot) {
    this.shadowRoot = shadowRoot;
    this.element = shadowRoot.host;
    this.document = this.element.ownerDocument;
    this.window = this.document.defaultView;
    // This needed for behavior=alternate, in order to know in which of the two
    // directions we're going.
    this.dirsign = 1;
    this._currentLoop = this.element.loop;
    this.animation = null;
    this._restartScheduled = null;
  }

  onsetup() {
    // White-space isn't allowed because a marquee could be
    // inside 'white-space: pre'
    this.shadowRoot.innerHTML = `<link rel="stylesheet" href="chrome://global/content/elements/marquee.css"
      /><slot></slot>`;

    this._mutationObserver = new this.window.MutationObserver(aMutations =>
      this._mutationActor(aMutations)
    );
    this._mutationObserver.observe(this.element, {
      attributes: true,
      attributeOldValue: true,
      attributeFilter: ["loop", "direction", "behavior"],
    });

    // init needs to be run after the page has loaded in order to calculate
    // the correct height/width
    if (this.document.readyState == "complete") {
      this.init();
    } else {
      this.window.addEventListener("load", this, { once: true });
    }

    this.shadowRoot.addEventListener("marquee-start", this);
    this.shadowRoot.addEventListener("marquee-stop", this);
  }

  teardown() {
    this.doStop();
    this._mutationObserver.disconnect();

    this.window.removeEventListener("load", this);
    this.shadowRoot.removeEventListener("marquee-start", this);
    this.shadowRoot.removeEventListener("marquee-stop", this);
    this.shadowRoot.replaceChildren();
  }

  handleEvent(aEvent) {
    if (!aEvent.isTrusted) {
      return;
    }

    switch (aEvent.type) {
      case "load":
        this.init();
        break;
      case "marquee-start":
        this.doStart();
        break;
      case "marquee-stop":
        this.doStop();
        break;
      case "finish":
        this._animationFinished();
        break;
    }
  }

  _animationFinished() {
    let behavior = this.element.behavior;
    let shouldLoop =
      this._currentLoop > 1 || (this._currentLoop == -1 && behavior != "slide");
    if (shouldLoop) {
      if (this._currentLoop > 0) {
        this._currentLoop--;
      }
      if (behavior == "alternate") {
        this.dirsign = -this.dirsign;
      }
      this.doStop();
      this.doStart();
    }
  }

  get scrollDelayWithTruespeed() {
    if (this.element.scrollDelay < 60 && !this.element.trueSpeed) {
      return 60;
    }
    return this.element.scrollDelay;
  }

  get slot() {
    return this.shadowRoot.lastChild;
  }

  /**
   * Computes CSS-derived values needed to compute the transform of the
   * contents.
   *
   * In particular, it measures the auto width and height of the contents,
   * and the effective width and height of the marquee itself, along with its
   * css directionality (which affects the effective direction).
   */
  getMetrics() {
    let slot = this.slot;
    slot.style.width = "max-content";
    let slotCS = this.window.getComputedStyle(slot);
    let marqueeCS = this.window.getComputedStyle(this.element);
    let contentWidth = parseFloat(slotCS.width) || 0;
    let contentHeight = parseFloat(slotCS.height) || 0;
    let marqueeWidth = parseFloat(marqueeCS.width) || 0;
    let marqueeHeight = parseFloat(marqueeCS.height) || 0;
    slot.style.width = "";
    return {
      contentWidth,
      contentHeight,
      marqueeWidth,
      marqueeHeight,
      cssDirection: marqueeCS.direction,
    };
  }

  /**
   * Gets the layout metrics from getMetrics(), and returns an object
   * describing the start, end, and axis of the animation for the given marquee
   * behavior and direction.
   */
  getTransformParameters({
    contentWidth,
    contentHeight,
    marqueeWidth,
    marqueeHeight,
    cssDirection,
  }) {
    const innerWidth = marqueeWidth - contentWidth;
    const innerHeight = marqueeHeight - contentHeight;
    const dir = this.element.direction;

    let start = 0;
    let end = 0;
    const axis = dir == "up" || dir == "down" ? "y" : "x";
    switch (this.element.behavior) {
      case "alternate":
        switch (dir) {
          case "up":
          case "down": {
            if (innerHeight >= 0) {
              start = innerHeight;
              end = 0;
            } else {
              start = 0;
              end = innerHeight;
            }
            if (dir == "down") {
              [start, end] = [end, start];
            }
            if (this.dirsign == -1) {
              [start, end] = [end, start];
            }
            break;
          }
          case "right":
          case "left":
          default: {
            if (innerWidth >= 0) {
              start = innerWidth;
              end = 0;
            } else {
              start = 0;
              end = innerWidth;
            }
            if (dir == "right") {
              [start, end] = [end, start];
            }
            if (cssDirection == "rtl") {
              [start, end] = [end, start];
            }
            if (this.dirsign == -1) {
              [start, end] = [end, start];
            }
            break;
          }
        }
        break;
      case "slide":
        switch (dir) {
          case "up": {
            start = marqueeHeight;
            end = 0;
            break;
          }
          case "down": {
            start = -contentHeight;
            end = innerHeight;
            break;
          }
          case "right":
          default: {
            let isRight = dir == "right";
            if (cssDirection == "rtl") {
              isRight = !isRight;
            }
            if (isRight) {
              start = -contentWidth;
              end = innerWidth;
            } else {
              start = marqueeWidth;
              end = 0;
            }
            break;
          }
        }
        break;
      case "scroll":
      default:
        switch (dir) {
          case "up":
          case "down": {
            start = marqueeHeight;
            end = -contentHeight;
            if (dir == "down") {
              [start, end] = [end, start];
            }
            break;
          }
          case "right":
          case "left":
          default: {
            start = marqueeWidth;
            end = -contentWidth;
            if (dir == "right") {
              [start, end] = [end, start];
            }
            if (cssDirection == "rtl") {
              [start, end] = [end, start];
            }
            break;
          }
        }
        break;
    }
    return { start, end, axis };
  }

  /**
   * Measures the marquee contents, and starts the marquee animation if needed.
   * The translate animation is applied to the <slot> element.
   * Bouncing and looping is implemented in the finish event handler for the
   * given animation (see _animationFinished()).
   */
  doStart() {
    if (this.animation) {
      return;
    }
    let scrollAmount = this.element.scrollAmount;
    if (!scrollAmount) {
      return;
    }
    let metrics = this.getMetrics();
    let { axis, start, end } = this.getTransformParameters(metrics);
    let duration =
      (Math.abs(end - start) * this.scrollDelayWithTruespeed) / scrollAmount;
    let startValue = start + "px";
    let endValue = end + "px";
    if (axis == "y") {
      startValue = "0 " + startValue;
      endValue = "0 " + endValue;
    }
    // NOTE(emilio): It seems tempting to use `iterations` here, but doing so
    // wouldn't be great because this uses current layout values (via
    // getMetrics()), so sizes wouldn't update. This way we update once per
    // animation iteration.
    //
    // fill: forwards is needed so that behavior=slide doesn't jump back to the
    // start after the animation finishes.
    this.animation = this.slot.animate(
      {
        translate: [startValue, endValue],
      },
      {
        duration,
        easing: "linear",
        fill: "forwards",
      }
    );
    this.animation.addEventListener("finish", this, { once: true });
  }

  doStop() {
    if (!this.animation) {
      return;
    }
    if (this._restartScheduled) {
      this.window.cancelAnimationFrame(this._restartScheduled);
      this._restartScheduled = null;
    }
    this.animation.removeEventListener("finish", this);
    this.animation.cancel();
    this.animation = null;
  }

  init() {
    this.element.stop();
    this.doStart();
  }

  _mutationActor(aMutations) {
    while (aMutations.length) {
      let mutation = aMutations.shift();
      let attrName = mutation.attributeName.toLowerCase();
      let oldValue = mutation.oldValue;
      let newValue = this.element.getAttribute(attrName);
      if (oldValue == newValue) {
        continue;
      }
      if (attrName == "loop") {
        this._currentLoop = this.element.loop;
      }
      if (attrName == "direction" || attrName == "behavior") {
        this._scheduleRestartIfNeeded();
      }
    }
  }

  // Schedule a restart with the new parameters if we're running.
  _scheduleRestartIfNeeded() {
    if (!this.animation || this._restartScheduled != null) {
      return;
    }
    this._restartScheduled = this.window.requestAnimationFrame(() => {
      if (this.animation) {
        this.doStop();
        this.doStart();
      }
    });
  }
};
