/* 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";

// The panel module currently supports only Firefox.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps
module.metadata = {
  "stability": "stable",
  "engines": {
    "Firefox": "*"
  }
};

const { Ci } = require("chrome");
const { validateOptions: valid } = require('./deprecated/api-utils');
const { Symbiont } = require('./content/content');
const { EventEmitter } = require('./deprecated/events');
const { setTimeout } = require('./timers');
const runtime = require('./system/runtime');
const { getDocShell } = require("./frame/utils");
const { getWindow } = require('./panel/window');
const { isPrivateBrowsingSupported } = require('./self');
const { isWindowPBSupported } = require('./private-browsing/utils');

const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul",
      ON_SHOW = 'popupshown',
      ON_HIDE = 'popuphidden',
      validNumber = { is: ['number', 'undefined', 'null'] };

if (isPrivateBrowsingSupported && isWindowPBSupported) {
  throw Error('The panel module cannot be used with per-window private browsing at the moment, see Bug 816257');
}

/**
 * Emits show and hide events.
 */
const Panel = Symbiont.resolve({
  constructor: '_init',
  _onInit: '_onSymbiontInit',
  destroy: '_symbiontDestructor',
  _documentUnload: '_workerDocumentUnload'
}).compose({
  _frame: Symbiont.required,
  _init: Symbiont.required,
  _onSymbiontInit: Symbiont.required,
  _symbiontDestructor: Symbiont.required,
  _emit: Symbiont.required,
  on: Symbiont.required,
  removeListener: Symbiont.required,

  _inited: false,

  /**
   * If set to `true` frame loaders between xul panel frame and
   * hidden frame are swapped. If set to `false` frame loaders are
   * set back to normal. Setting the value that was already set will
   * have no effect.
   */
  set _frameLoadersSwapped(value) {
    if (this.__frameLoadersSwapped == value) return;
    this._frame.QueryInterface(Ci.nsIFrameLoaderOwner)
      .swapFrameLoaders(this._viewFrame);
    this.__frameLoadersSwapped = value;
  },
  __frameLoadersSwapped: false,

  constructor: function Panel(options) {
    this._onShow = this._onShow.bind(this);
    this._onHide = this._onHide.bind(this);
    this.on('inited', this._onSymbiontInit.bind(this));
    this.on('propertyChange', this._onChange.bind(this));

    options = options || {};
    if ('onShow' in options)
      this.on('show', options.onShow);
    if ('onHide' in options)
      this.on('hide', options.onHide);
    if ('width' in options)
      this.width = options.width;
    if ('height' in options)
      this.height = options.height;
    if ('contentURL' in options)
      this.contentURL = options.contentURL;

    this._init(options);
  },
  _destructor: function _destructor() {
    this.hide();
    this._removeAllListeners('show');
    this._removeAllListeners('hide');
    this._removeAllListeners('propertyChange');
    this._removeAllListeners('inited');
    // defer cleanup to be performed after panel gets hidden
    this._xulPanel = null;
    this._symbiontDestructor(this);
    this._removeAllListeners();
  },
  destroy: function destroy() {
    this._destructor();
  },
  /* Public API: Panel.width */
  get width() this._width,
  set width(value)
    this._width = valid({ $: value }, { $: validNumber }).$ || this._width,
  _width: 320,
  /* Public API: Panel.height */
  get height() this._height,
  set height(value)
    this._height =  valid({ $: value }, { $: validNumber }).$ || this._height,
  _height: 240,

  /* Public API: Panel.isShowing */
  get isShowing() !!this._xulPanel && this._xulPanel.state == "open",

  /* Public API: Panel.show */
  show: function show(anchor) {
    anchor = anchor || null;
    let anchorWindow = getWindow(anchor);

    // If there is no open window, or the anchor is in a private window
    // then we will not be able to display the panel
    if (!anchorWindow) {
      return;
    }

    let document = anchorWindow.document;
    let xulPanel = this._xulPanel;
    if (!xulPanel) {
      xulPanel = this._xulPanel = document.createElementNS(XUL_NS, 'panel');
      xulPanel.setAttribute("type", "arrow");

      // One anonymous node has a big padding that doesn't work well with
      // Jetpack, as we would like to display an iframe that completely fills
      // the panel.
      // -> Use a XBL wrapper with inner stylesheet to remove this padding.
      let css = ".panel-inner-arrowcontent, .panel-arrowcontent {padding: 0;}";
      let originalXBL = "chrome://global/content/bindings/popup.xml#arrowpanel";
      let binding =
      '<bindings xmlns="http://www.mozilla.org/xbl">' +
        '<binding id="id" extends="' + originalXBL + '">' +
          '<resources>' +
            '<stylesheet src="data:text/css;charset=utf-8,' +
              document.defaultView.encodeURIComponent(css) + '"/>' +
          '</resources>' +
        '</binding>' +
      '</bindings>';
      xulPanel.style.MozBinding = 'url("data:text/xml;charset=utf-8,' +
        document.defaultView.encodeURIComponent(binding) + '")';

      let frame = document.createElementNS(XUL_NS, 'iframe');
      frame.setAttribute('type', 'content');
      frame.setAttribute('flex', '1');
      frame.setAttribute('transparent', 'transparent');

      if (runtime.OS === "Darwin") {
        frame.style.borderRadius = "6px";
        frame.style.padding = "1px";
      }

      // Load an empty document in order to have an immediatly loaded iframe,
      // so swapFrameLoaders is going to work without having to wait for load.
      frame.setAttribute("src","data:;charset=utf-8,");

      xulPanel.appendChild(frame);
      document.getElementById("mainPopupSet").appendChild(xulPanel);
    }
    let { width, height } = this, x, y, position;

    if (!anchor) {
      // Open the popup in the middle of the window.
      x = document.documentElement.clientWidth / 2 - width / 2;
      y = document.documentElement.clientHeight / 2 - height / 2;
      position = null;
    }
    else {
      // Open the popup by the anchor.
      let rect = anchor.getBoundingClientRect();

      let window = anchor.ownerDocument.defaultView;

      let zoom = window.mozScreenPixelsPerCSSPixel;
      let screenX = rect.left + window.mozInnerScreenX * zoom;
      let screenY = rect.top + window.mozInnerScreenY * zoom;

      // Set up the vertical position of the popup relative to the anchor
      // (always display the arrow on anchor center)
      let horizontal, vertical;
      if (screenY > window.screen.availHeight / 2 + height)
        vertical = "top";
      else
        vertical = "bottom";

      if (screenY > window.screen.availWidth / 2 + width)
        horizontal = "left";
      else
        horizontal = "right";

      let verticalInverse = vertical == "top" ? "bottom" : "top";
      position = vertical + "center " + verticalInverse + horizontal;

      // Allow panel to flip itself if the panel can't be displayed at the
      // specified position (useful if we compute a bad position or if the
      // user moves the window and panel remains visible)
      xulPanel.setAttribute("flip","both");
    }

    // Resize the iframe instead of using panel.sizeTo
    // because sizeTo doesn't work with arrow panels
    xulPanel.firstChild.style.width = width + "px";
    xulPanel.firstChild.style.height = height + "px";

    // Wait for the XBL binding to be constructed
    function waitForBinding() {
      if (!xulPanel.openPopup) {
        setTimeout(waitForBinding, 50);
        return;
      }
      xulPanel.openPopup(anchor, position, x, y);
    }
    waitForBinding();

    return this._public;
  },
  /* Public API: Panel.hide */
  hide: function hide() {
    // The popuphiding handler takes care of swapping back the frame loaders
    // and removing the XUL panel from the application window, we just have to
    // trigger it by hiding the popup.
    // XXX Sometimes I get "TypeError: xulPanel.hidePopup is not a function"
    // when quitting the host application while a panel is visible.  To suppress
    // them, this now checks for "hidePopup" in xulPanel before calling it.
    // It's not clear if there's an actual issue or the error is just normal.
    let xulPanel = this._xulPanel;
    if (xulPanel && "hidePopup" in xulPanel)
      xulPanel.hidePopup();
    return this._public;
  },

  /* Public API: Panel.resize */
  resize: function resize(width, height) {
    this.width = width;
    this.height = height;
    // Resize the iframe instead of using panel.sizeTo
    // because sizeTo doesn't work with arrow panels
    let xulPanel = this._xulPanel;
    if (xulPanel) {
      xulPanel.firstChild.style.width = width + "px";
      xulPanel.firstChild.style.height = height + "px";
    }
  },

  // While the panel is visible, this is the XUL <panel> we use to display it.
  // Otherwise, it's null.
  get _xulPanel() this.__xulPanel,
  set _xulPanel(value) {
    let xulPanel = this.__xulPanel;
    if (value === xulPanel) return;
    if (xulPanel) {
      xulPanel.removeEventListener(ON_HIDE, this._onHide, false);
      xulPanel.removeEventListener(ON_SHOW, this._onShow, false);
      xulPanel.parentNode.removeChild(xulPanel);
    }
    if (value) {
      value.addEventListener(ON_HIDE, this._onHide, false);
      value.addEventListener(ON_SHOW, this._onShow, false);
    }
    this.__xulPanel = value;
  },
  __xulPanel: null,
  get _viewFrame() this.__xulPanel.children[0],
  /**
   * When the XUL panel becomes hidden, we swap frame loaders back to move
   * the content of the panel to the hidden frame & remove panel element.
   */
  _onHide: function _onHide() {
    try {
      this._frameLoadersSwapped = false;
      this._xulPanel = null;
      this._emit('hide');
    } catch(e) {
      this._emit('error', e);
    }
  },

  /**
   * Retrieve computed text color style in order to apply to the iframe
   * document. As MacOS background is dark gray, we need to use skin's
   * text color.
   */
  _applyStyleToDocument: function _applyStyleToDocument() {
    try {
      let win = this._xulPanel.ownerDocument.defaultView;
      let node = win.document.getAnonymousElementByAttribute(
        this._xulPanel, "class", "panel-arrowcontent");
      if (!node) {
        // Before bug 764755, anonymous content was different:
        // TODO: Remove this when targeting FF16+
        node = win.document.getAnonymousElementByAttribute(
          this._xulPanel, "class", "panel-inner-arrowcontent");
      }
      let textColor = win.getComputedStyle(node).getPropertyValue("color");
      let doc = this._xulPanel.firstChild.contentDocument;
      let style = doc.createElement("style");
      style.textContent = "body { color: " + textColor + "; }";
      let container = doc.head ? doc.head : doc.documentElement;

      if (container.firstChild)
        container.insertBefore(style, container.firstChild);
      else
        container.appendChild(style);
    }
    catch(e) {
      console.error("Unable to apply panel style");
      console.exception(e);
    }
  },

  /**
   * When the XUL panel becomes shown, we swap frame loaders between panel
   * frame and hidden frame to preserve state of the content dom.
   */
  _onShow: function _onShow() {
    try {
      if (!this._inited) { // defer if not initialized yet
        this.on('inited', this._onShow.bind(this));
      } else {
        this._frameLoadersSwapped = true;
        this._applyStyleToDocument();
        this._emit('show');
      }
    } catch(e) {
      this._emit('error', e);
    }
  },
  /**
   * Notification that panel was fully initialized.
   */
  _onInit: function _onInit() {
    this._inited = true;

    // Avoid panel document from resizing the browser window
    // New platform capability added through bug 635673
    let docShell = getDocShell(this._frame);
    if (docShell && "allowWindowControl" in docShell)
      docShell.allowWindowControl = false;

    // perform all deferred tasks like initSymbiont, show, hide ...
    // TODO: We're publicly exposing a private event here; this
    // 'inited' event should really be made private, somehow.
    this._emit('inited');
  },

  // Catch document unload event in order to rebind load event listener with
  // Symbiont._initFrame if Worker._documentUnload destroyed the worker
  _documentUnload: function(subject, topic, data) {
    if (this._workerDocumentUnload(subject, topic, data)) {
      this._initFrame(this._frame);
      return true;
    }
    return false;
  },

  _onChange: function _onChange(e) {
    this._frameLoadersSwapped = false;
    if ('contentURL' in e && this._frame) {
      // Cleanup the worker before injecting the content script in the new
      // document
      this._workerCleanup();
      this._initFrame(this._frame);
    }
  }
});
exports.Panel = function(options) Panel(options)
exports.Panel.prototype = Panel.prototype;
