/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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/. */
/* globals ViewHelpers */

"use strict";

// Set of reusable UI components for the animation-inspector UI.
// All components in this module share a common API:
// 1. construct the component:
//    let c = new ComponentName();
// 2. initialize the markup of the component in a given parent node:
//    c.init(containerElement);
// 3. render the component, passing in some sort of state:
//    This may be called over and over again when the state changes, to update
//    the component output.
//    c.render(state);
// 4. destroy the component:
//    c.destroy();

const {Cu} = require("chrome");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
const {
  createNode,
  drawGraphElementBackground,
  findOptimalTimeInterval
} = require("devtools/animationinspector/utils");

const STRINGS_URI = "chrome://browser/locale/devtools/animationinspector.properties";
const L10N = new ViewHelpers.L10N(STRINGS_URI);
const MILLIS_TIME_FORMAT_MAX_DURATION = 4000;
// The minimum spacing between 2 time graduation headers in the timeline (ms).
const TIME_GRADUATION_MIN_SPACING = 40;

/**
 * UI component responsible for displaying and updating the player meta-data:
 * name, duration, iterations, delay.
 * The parent UI component for this should drive its updates by calling
 * render(state) whenever it wants the component to update.
 */
function PlayerMetaDataHeader() {
  // Store the various state pieces we need to only refresh the UI when things
  // change.
  this.state = {};
}

exports.PlayerMetaDataHeader = PlayerMetaDataHeader;

PlayerMetaDataHeader.prototype = {
  init: function(containerEl) {
    // The main title element.
    this.el = createNode({
      parent: containerEl,
      attributes: {
        "class": "animation-title"
      }
    });

    // Animation name.
    this.nameLabel = createNode({
      parent: this.el,
      nodeType: "span"
    });

    this.nameValue = createNode({
      parent: this.el,
      nodeType: "strong",
      attributes: {
        "style": "display:none;"
      }
    });

    // Animation duration, delay and iteration container.
    let metaData = createNode({
      parent: this.el,
      nodeType: "span",
      attributes: {
        "class": "meta-data"
      }
    });

    // Animation is running on compositor
    this.compositorIcon = createNode({
      parent: metaData,
      nodeType: "span",
      attributes: {
        "class": "compositor-icon",
        "title": L10N.getStr("player.runningOnCompositorTooltip")
      }
    });

    // Animation duration.
    this.durationLabel = createNode({
      parent: metaData,
      nodeType: "span",
      textContent: L10N.getStr("player.animationDurationLabel")
    });

    this.durationValue = createNode({
      parent: metaData,
      nodeType: "strong"
    });

    // Animation delay (hidden by default since there may not be a delay).
    this.delayLabel = createNode({
      parent: metaData,
      nodeType: "span",
      attributes: {
        "style": "display:none;"
      },
      textContent: L10N.getStr("player.animationDelayLabel")
    });

    this.delayValue = createNode({
      parent: metaData,
      nodeType: "strong"
    });

    // Animation iteration count (also hidden by default since we don't display
    // single iterations).
    this.iterationLabel = createNode({
      parent: metaData,
      nodeType: "span",
      attributes: {
        "style": "display:none;"
      },
      textContent: L10N.getStr("player.animationIterationCountLabel")
    });

    this.iterationValue = createNode({
      parent: metaData,
      nodeType: "strong",
      attributes: {
        "style": "display:none;"
      }
    });
  },

  destroy: function() {
    this.state = null;
    this.el.remove();
    this.el = null;
    this.nameLabel = this.nameValue = null;
    this.durationLabel = this.durationValue = null;
    this.delayLabel = this.delayValue = null;
    this.iterationLabel = this.iterationValue = null;
    this.compositorIcon = null;
  },

  render: function(state) {
    // Update the name if needed.
    if (state.name !== this.state.name) {
      if (state.name) {
        // Animations (and transitions since bug 1122414) have names.
        this.nameLabel.textContent = L10N.getStr("player.animationNameLabel");
        this.nameValue.style.display = "inline";
        this.nameValue.textContent = state.name;
      } else {
        // With older actors, Css transitions don't have names.
        this.nameLabel.textContent = L10N.getStr("player.transitionNameLabel");
        this.nameValue.style.display = "none";
      }
    }

    // update the duration value if needed.
    if (state.duration !== this.state.duration) {
      this.durationValue.textContent = L10N.getFormatStr("player.timeLabel",
        L10N.numberWithDecimals(state.duration / 1000, 2));
    }

    // Update the delay if needed.
    if (state.delay !== this.state.delay) {
      if (state.delay) {
        this.delayLabel.style.display = "inline";
        this.delayValue.style.display = "inline";
        this.delayValue.textContent = L10N.getFormatStr("player.timeLabel",
          L10N.numberWithDecimals(state.delay / 1000, 2));
      } else {
        // Hide the delay elements if there is no delay defined.
        this.delayLabel.style.display = "none";
        this.delayValue.style.display = "none";
      }
    }

    // Update the iterationCount if needed.
    if (state.iterationCount !== this.state.iterationCount) {
      if (state.iterationCount !== 1) {
        this.iterationLabel.style.display = "inline";
        this.iterationValue.style.display = "inline";
        let count = state.iterationCount ||
                    L10N.getStr("player.infiniteIterationCount");
        this.iterationValue.innerHTML = count;
      } else {
        // Hide the iteration elements if iteration is 1.
        this.iterationLabel.style.display = "none";
        this.iterationValue.style.display = "none";
      }
    }

    // Show the Running on compositor icon if needed.
    if (state.isRunningOnCompositor !== this.state.isRunningOnCompositor) {
      if (state.isRunningOnCompositor) {
        this.compositorIcon.style.display = "inline";
      } else {
        // Hide the compositor icon
        this.compositorIcon.style.display = "none";
      }
    }

    this.state = state;
  }
};

/**
 * UI component responsible for displaying the playback rate drop-down in each
 * player widget, updating it when the state changes, and emitting events when
 * the user selects a new value.
 * The parent UI component for this should drive its updates by calling
 * render(state) whenever it wants the component to update.
 */
function PlaybackRateSelector() {
  this.currentRate = null;
  this.onSelectionChanged = this.onSelectionChanged.bind(this);
  EventEmitter.decorate(this);
}

exports.PlaybackRateSelector = PlaybackRateSelector;

PlaybackRateSelector.prototype = {
  PRESETS: [.1, .5, 1, 2, 5, 10],

  init: function(containerEl) {
    // This component is simple enough that we can re-create the markup every
    // time it's rendered. So here we only store the parentEl.
    this.parentEl = containerEl;
  },

  destroy: function() {
    this.removeSelect();
    this.parentEl = this.el = null;
  },

  removeSelect: function() {
    if (this.el) {
      this.el.removeEventListener("change", this.onSelectionChanged);
      this.el.remove();
    }
  },

  /**
   * Get the ordered list of presets, including the current playbackRate if
   * different from the existing presets.
   */
  getCurrentPresets: function({playbackRate}) {
    return [...new Set([...this.PRESETS, playbackRate])].sort((a, b) => a > b);
  },

  render: function(state) {
    if (state.playbackRate === this.currentRate) {
      return;
    }

    this.removeSelect();

    this.el = createNode({
      parent: this.parentEl,
      nodeType: "select",
      attributes: {
        "class": "rate devtools-button"
      }
    });

    for (let preset of this.getCurrentPresets(state)) {
      let option = createNode({
        parent: this.el,
        nodeType: "option",
        attributes: {
          value: preset,
        },
        textContent: L10N.getFormatStr("player.playbackRateLabel", preset)
      });
      if (preset === state.playbackRate) {
        option.setAttribute("selected", "");
      }
    }

    this.el.addEventListener("change", this.onSelectionChanged);

    this.currentRate = state.playbackRate;
  },

  onSelectionChanged: function() {
    this.emit("rate-changed", parseFloat(this.el.value));
  }
};

/**
 * UI component responsible for displaying a preview of the target dom node of
 * a given animation.
 * @param {InspectorPanel} inspector Requires a reference to the inspector-panel
 * to highlight and select the node, as well as refresh it when there are
 * mutations.
 * @param {Object} options Supported properties are:
 * - compact {Boolean} Defaults to false. If true, nodes will be previewed like
 *   tag#id.class instead of <tag id="id" class="class">
 */
function AnimationTargetNode(inspector, options={}) {
  this.inspector = inspector;
  this.options = options;

  this.onPreviewMouseOver = this.onPreviewMouseOver.bind(this);
  this.onPreviewMouseOut = this.onPreviewMouseOut.bind(this);
  this.onSelectNodeClick = this.onSelectNodeClick.bind(this);
  this.onMarkupMutations = this.onMarkupMutations.bind(this);

  EventEmitter.decorate(this);
}

exports.AnimationTargetNode = AnimationTargetNode;

AnimationTargetNode.prototype = {
  init: function(containerEl) {
    let document = containerEl.ownerDocument;

    // Init the markup for displaying the target node.
    this.el = createNode({
      parent: containerEl,
      attributes: {
        "class": "animation-target"
      }
    });

    // Icon to select the node in the inspector.
    this.selectNodeEl = createNode({
      parent: this.el,
      nodeType: "span",
      attributes: {
        "class": "node-selector"
      }
    });

    // Wrapper used for mouseover/out event handling.
    this.previewEl = createNode({
      parent: this.el,
      nodeType: "span"
    });

    if (!this.options.compact) {
      this.previewEl.appendChild(document.createTextNode("<"));
    }

    // Tag name.
    this.tagNameEl = createNode({
      parent: this.previewEl,
      nodeType: "span",
      attributes: {
        "class": "tag-name theme-fg-color3"
      }
    });

    // Id attribute container.
    this.idEl = createNode({
      parent: this.previewEl,
      nodeType: "span"
    });

    if (!this.options.compact) {
      createNode({
        parent: this.idEl,
        nodeType: "span",
        attributes: {
          "class": "attribute-name theme-fg-color2"
        },
        textContent: "id"
      });
      this.idEl.appendChild(document.createTextNode("=\""));
    } else {
      createNode({
        parent: this.idEl,
        nodeType: "span",
        attributes: {
          "class": "theme-fg-color2"
        },
        textContent: "#"
      });
    }

    createNode({
      parent: this.idEl,
      nodeType: "span",
      attributes: {
        "class": "attribute-value theme-fg-color6"
      }
    });

    if (!this.options.compact) {
      this.idEl.appendChild(document.createTextNode("\""));
    }

    // Class attribute container.
    this.classEl = createNode({
      parent: this.previewEl,
      nodeType: "span"
    });

    if (!this.options.compact) {
      createNode({
        parent: this.classEl,
        nodeType: "span",
        attributes: {
          "class": "attribute-name theme-fg-color2"
        },
        textContent: "class"
      });
      this.classEl.appendChild(document.createTextNode("=\""));
    } else {
      createNode({
        parent: this.classEl,
        nodeType: "span",
        attributes: {
          "class": "theme-fg-color6"
        },
        textContent: "."
      });
    }

    createNode({
      parent: this.classEl,
      nodeType: "span",
      attributes: {
        "class": "attribute-value theme-fg-color6"
      }
    });

    if (!this.options.compact) {
      this.classEl.appendChild(document.createTextNode("\""));
      this.previewEl.appendChild(document.createTextNode(">"));
    }

    // Init events for highlighting and selecting the node.
    this.previewEl.addEventListener("mouseover", this.onPreviewMouseOver);
    this.previewEl.addEventListener("mouseout", this.onPreviewMouseOut);
    this.selectNodeEl.addEventListener("click", this.onSelectNodeClick);

    // Start to listen for markupmutation events.
    this.inspector.on("markupmutation", this.onMarkupMutations);
  },

  destroy: function() {
    this.inspector.off("markupmutation", this.onMarkupMutations);
    this.previewEl.removeEventListener("mouseover", this.onPreviewMouseOver);
    this.previewEl.removeEventListener("mouseout", this.onPreviewMouseOut);
    this.selectNodeEl.removeEventListener("click", this.onSelectNodeClick);
    this.el.remove();
    this.el = this.tagNameEl = this.idEl = this.classEl = null;
    this.selectNodeEl = this.previewEl = null;
    this.nodeFront = this.inspector = this.playerFront = null;
  },

  onPreviewMouseOver: function() {
    if (!this.nodeFront) {
      return;
    }
    this.inspector.toolbox.highlighterUtils.highlightNodeFront(this.nodeFront);
  },

  onPreviewMouseOut: function() {
    this.inspector.toolbox.highlighterUtils.unhighlight();
  },

  onSelectNodeClick: function() {
    if (!this.nodeFront) {
      return;
    }
    this.inspector.selection.setNodeFront(this.nodeFront, "animationinspector");
  },

  onMarkupMutations: function(e, mutations) {
    if (!this.nodeFront || !this.playerFront) {
      return;
    }

    for (let {target} of mutations) {
      if (target === this.nodeFront) {
        // Re-render with the same nodeFront to update the output.
        this.render(this.playerFront);
        break;
      }
    }
  },

  render: Task.async(function*(playerFront) {
    this.playerFront = playerFront;
    this.nodeFront = undefined;

    try {
      this.nodeFront = yield this.inspector.walker.getNodeFromActor(
                             playerFront.actorID, ["node"]);
    } catch (e) {
      // We might have been destroyed in the meantime, or the node might not be
      // found.
      if (!this.el) {
        console.warn("Cound't retrieve the animation target node, widget " +
                     "destroyed");
      }
      console.error(e);
      return;
    }

    if (!this.nodeFront || !this.el) {
      return;
    }

    let {tagName, attributes} = this.nodeFront;

    this.tagNameEl.textContent = tagName.toLowerCase();

    let idIndex = attributes.findIndex(({name}) => name === "id");
    if (idIndex > -1 && attributes[idIndex].value) {
      this.idEl.querySelector(".attribute-value").textContent =
        attributes[idIndex].value;
      this.idEl.style.display = "inline";
    } else {
      this.idEl.style.display = "none";
    }

    let classIndex = attributes.findIndex(({name}) => name === "class");
    if (classIndex > -1 && attributes[classIndex].value) {
      let value = attributes[classIndex].value;
      if (this.options.compact) {
        value = value.split(" ").join(".");
      }

      this.classEl.querySelector(".attribute-value").textContent = value;
      this.classEl.style.display = "inline";
    } else {
      this.classEl.style.display = "none";
    }

    this.emit("target-retrieved");
  })
};

/**
 * The TimeScale helper object is used to know which size should something be
 * displayed with in the animation panel, depending on the animations that are
 * currently displayed.
 * If there are 5 animations displayed, and the first one starts at 10000ms and
 * the last one ends at 20000ms, then this helper can be used to convert any
 * time in this range to a distance in pixels.
 *
 * For the helper to know how to convert, it needs to know all the animations.
 * Whenever a new animation is added to the panel, addAnimation(state) should be
 * called. reset() can be called to start over.
 */
let TimeScale = {
  minStartTime: Infinity,
  maxEndTime: 0,

  /**
   * Add a new animation to time scale.
   * @param {Object} state A PlayerFront.state object.
   */
  addAnimation: function({startTime, delay, duration, iterationCount}) {
    this.minStartTime = Math.min(this.minStartTime, startTime);
    let length = delay + (duration * (!iterationCount ? 1 : iterationCount));
    this.maxEndTime = Math.max(this.maxEndTime, startTime + length);
  },

  /**
   * Reset the current time scale.
   */
  reset: function() {
    this.minStartTime = Infinity;
    this.maxEndTime = 0;
  },

  /**
   * Convert a startTime to a distance in pixels, in the current time scale.
   * @param {Number} time
   * @param {Number} containerWidth The width of the container element.
   * @return {Number}
   */
  startTimeToDistance: function(time, containerWidth) {
    time -= this.minStartTime;
    return this.durationToDistance(time, containerWidth);
  },

  /**
   * Convert a duration to a distance in pixels, in the current time scale.
   * @param {Number} time
   * @param {Number} containerWidth The width of the container element.
   * @return {Number}
   */
  durationToDistance: function(duration, containerWidth) {
    return containerWidth * duration / (this.maxEndTime - this.minStartTime);
  },

  /**
   * Convert a distance in pixels to a time, in the current time scale.
   * @param {Number} distance
   * @param {Number} containerWidth The width of the container element.
   * @return {Number}
   */
  distanceToTime: function(distance, containerWidth) {
    return this.minStartTime +
      ((this.maxEndTime - this.minStartTime) * distance / containerWidth);
  },

  /**
   * Convert a distance in pixels to a time, in the current time scale.
   * The time will be relative to the current minimum start time.
   * @param {Number} distance
   * @param {Number} containerWidth The width of the container element.
   * @return {Number}
   */
  distanceToRelativeTime: function(distance, containerWidth) {
    let time = this.distanceToTime(distance, containerWidth);
    return time - this.minStartTime;
  },

  /**
   * Depending on the time scale, format the given time as milliseconds or
   * seconds.
   * @param {Number} time
   * @return {String} The formatted time string.
   */
  formatTime: function(time) {
    let duration = this.maxEndTime - this.minStartTime;

    // Format in milliseconds if the total duration is short enough.
    if (duration <= MILLIS_TIME_FORMAT_MAX_DURATION) {
      return L10N.getFormatStr("timeline.timeGraduationLabel", time.toFixed(0));
    }

    // Otherwise format in seconds.
    return L10N.getFormatStr("player.timeLabel", (time / 1000).toFixed(1));
  }
};

exports.TimeScale = TimeScale;

/**
 * UI component responsible for displaying a timeline for animations.
 * The timeline is essentially a graph with time along the x axis and animations
 * along the y axis.
 * The time is represented with a graduation header at the top and a current
 * time play head.
 * Animations are organized by lines, with a left margin containing the preview
 * of the target DOM element the animation applies to.
 */
function AnimationsTimeline(inspector) {
  this.animations = [];
  this.targetNodes = [];
  this.inspector = inspector;

  this.onAnimationStateChanged = this.onAnimationStateChanged.bind(this);
}

exports.AnimationsTimeline = AnimationsTimeline;

AnimationsTimeline.prototype = {
  init: function(containerEl) {
    this.win = containerEl.ownerDocument.defaultView;

    this.rootWrapperEl = createNode({
      parent: containerEl,
      attributes: {
        "class": "animation-timeline"
      }
    });

    this.timeHeaderEl = createNode({
      parent: this.rootWrapperEl,
      attributes: {
        "class": "time-header"
      }
    });

    this.animationsEl = createNode({
      parent: this.rootWrapperEl,
      nodeType: "ul",
      attributes: {
        "class": "animations"
      }
    });
  },

  destroy: function() {
    this.unrender();

    this.rootWrapperEl.remove();
    this.animations = [];

    this.rootWrapperEl = null;
    this.timeHeaderEl = null;
    this.animationsEl = null;
    this.win = null;
    this.inspector = null;
  },

  destroyTargetNodes: function() {
    for (let targetNode of this.targetNodes) {
      targetNode.destroy();
    }
    this.targetNodes = [];
  },

  unrender: function() {
    for (let animation of this.animations) {
      animation.off("changed", this.onAnimationStateChanged);
    }

    TimeScale.reset();
    this.destroyTargetNodes();
    this.animationsEl.innerHTML = "";
  },

  render: function(animations) {
    this.unrender();

    this.animations = animations;
    if (!this.animations.length) {
      return;
    }

    // Loop first to set the time scale for all current animations.
    for (let {state} of animations) {
      TimeScale.addAnimation(state);
    }

    this.drawHeaderAndBackground();

    for (let animation of this.animations) {
      animation.on("changed", this.onAnimationStateChanged);

      // Each line contains the target animated node and the animation time
      // block.
      let animationEl = createNode({
        parent: this.animationsEl,
        nodeType: "li",
        attributes: {
          "class": "animation"
        }
      });

      // Left sidebar for the animated node.
      let animatedNodeEl = createNode({
        parent: animationEl,
        attributes: {
          "class": "target"
        }
      });

      let timeBlockEl = createNode({
        parent: animationEl,
        attributes: {
          "class": "time-block"
        }
      });

      this.drawTimeBlock(animation, timeBlockEl);

      // Draw the animated node target.
      let targetNode = new AnimationTargetNode(this.inspector, {compact: true});
      targetNode.init(animatedNodeEl);
      targetNode.render(animation);

      // Save the targetNode so it can be destroyed later.
      this.targetNodes.push(targetNode);
    }
  },

  onAnimationStateChanged: function() {
    // For now, simply re-render the component. The animation front's state has
    // already been updated.
    this.render(this.animations);
  },

  drawHeaderAndBackground: function() {
    let width = this.timeHeaderEl.offsetWidth;
    let scale = width / (TimeScale.maxEndTime - TimeScale.minStartTime);
    drawGraphElementBackground(this.win.document, "time-graduations", width, scale);

    // And the time graduation header.
    this.timeHeaderEl.innerHTML = "";
    let interval = findOptimalTimeInterval(scale, TIME_GRADUATION_MIN_SPACING);
    for (let i = 0; i < width; i += interval) {
      createNode({
        parent: this.timeHeaderEl,
        nodeType: "span",
        attributes: {
          "class": "time-tick",
          "style": `left:${i}px`
        },
        textContent: TimeScale.formatTime(
          TimeScale.distanceToRelativeTime(i, width))
      });
    }
  },

  drawTimeBlock: function({state}, el) {
    let width = el.offsetWidth;

    // Container for all iterations and delay. Positioned at the right start
    // time.
    let x = TimeScale.startTimeToDistance(state.startTime + (state.delay || 0),
                                          width);
    // With the right width (duration*duration).
    let count = state.iterationCount || 1;
    let w = TimeScale.durationToDistance(state.duration, width);

    let iterations = createNode({
      parent: el,
      attributes: {
        "class": "iterations" + (state.iterationCount ? "" : " infinite"),
        // Individual iterations are represented by setting the size of the
        // repeating linear-gradient.
        "style": `left:${x}px;
                  width:${w * count}px;
                  background-size:${Math.max(w, 2)}px 100%;`
      }
    });

    // The animation name is displayed over the iterations.
    createNode({
      parent: iterations,
      attributes: {
        "class": "name"
      },
      textContent: state.name
    });

    // Delay.
    if (state.delay) {
      let delay = TimeScale.durationToDistance(state.delay, width);
      createNode({
        parent: iterations,
        attributes: {
          "class": "delay",
          "style": `left:-${delay}px;
                    width:${delay}px;`
        }
      });
    }
  }
};
