/* -*- js-indent-level: 2; indent-tabs-mode: nil -*- */
/* 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/. */

"use strict";

const {Cc, Ci, Cu} = require("chrome");

loader.lazyGetter(this, "NetworkHelper", () => require("devtools/toolkit/webconsole/network-helper"));
loader.lazyImporter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm");
loader.lazyServiceGetter(this, "mimeService", "@mozilla.org/mime;1", "nsIMIMEService");

let WebConsoleUtils = require("devtools/toolkit/webconsole/utils").Utils;

const STRINGS_URI = "chrome://browser/locale/devtools/webconsole.properties";
let l10n = new WebConsoleUtils.l10n(STRINGS_URI);


/**
 * Creates a new NetworkPanel.
 *
 * @constructor
 * @param nsIDOMNode aParent
 *        Parent node to append the created panel to.
 * @param object aHttpActivity
 *        HttpActivity to display in the panel.
 * @param object aWebConsoleFrame
 *        The parent WebConsoleFrame object that owns this network panel
 *        instance.
 */
function NetworkPanel(aParent, aHttpActivity, aWebConsoleFrame)
{
  let doc = aParent.ownerDocument;
  this.httpActivity = aHttpActivity;
  this.webconsole = aWebConsoleFrame;
  this._longStringClick = this._longStringClick.bind(this);
  this._responseBodyFetch = this._responseBodyFetch.bind(this);
  this._requestBodyFetch = this._requestBodyFetch.bind(this);

  // Create the underlaying panel
  this.panel = createElement(doc, "panel", {
    label: l10n.getStr("NetworkPanel.label"),
    titlebar: "normal",
    noautofocus: "true",
    noautohide: "true",
    close: "true"
  });

  // Create the iframe that displays the NetworkPanel XHTML.
  this.iframe = createAndAppendElement(this.panel, "iframe", {
    src: "chrome://browser/content/devtools/NetworkPanel.xhtml",
    type: "content",
    flex: "1"
  });

  let self = this;

  // Destroy the panel when it's closed.
  this.panel.addEventListener("popuphidden", function onPopupHide() {
    self.panel.removeEventListener("popuphidden", onPopupHide, false);
    self.panel.parentNode.removeChild(self.panel);
    self.panel = null;
    self.iframe = null;
    self.httpActivity = null;
    self.webconsole = null;

    if (self.linkNode) {
      self.linkNode._panelOpen = false;
      self.linkNode = null;
    }
  }, false);

  // Set the document object and update the content once the panel is loaded.
  this.iframe.addEventListener("load", function onLoad() {
    if (!self.iframe) {
      return;
    }

    self.iframe.removeEventListener("load", onLoad, true);
    self.update();
  }, true);

  this.panel.addEventListener("popupshown", function onPopupShown() {
    self.panel.removeEventListener("popupshown", onPopupShown, true);
    self.update();
  }, true);

  // Create the footer.
  let footer = createElement(doc, "hbox", { align: "end" });
  createAndAppendElement(footer, "spacer", { flex: 1 });

  createAndAppendElement(footer, "resizer", { dir: "bottomend" });
  this.panel.appendChild(footer);

  aParent.appendChild(this.panel);
}
exports.NetworkPanel = NetworkPanel;

NetworkPanel.prototype =
{
  /**
   * The current state of the output.
   */
  _state: 0,

  /**
   * State variables.
   */
  _INIT: 0,
  _DISPLAYED_REQUEST_HEADER: 1,
  _DISPLAYED_REQUEST_BODY: 2,
  _DISPLAYED_RESPONSE_HEADER: 3,
  _TRANSITION_CLOSED: 4,

  _fromDataRegExp: /Content-Type\:\s*application\/x-www-form-urlencoded/,

  _contentType: null,

  /**
   * Function callback invoked whenever the panel content is updated. This is
   * used only by tests.
   *
   * @private
   * @type function
   */
  _onUpdate: null,

  get document() {
    return this.iframe && this.iframe.contentWindow ?
           this.iframe.contentWindow.document : null;
  },

  /**
   * Small helper function that is nearly equal to l10n.getFormatStr
   * except that it prefixes aName with "NetworkPanel.".
   *
   * @param string aName
   *        The name of an i10n string to format. This string is prefixed with
   *        "NetworkPanel." before calling the HUDService.getFormatStr function.
   * @param array aArray
   *        Values used as placeholder for the i10n string.
   * @returns string
   *          The i10n formated string.
   */
  _format: function NP_format(aName, aArray)
  {
    return l10n.getFormatStr("NetworkPanel." + aName, aArray);
  },

  /**
   * Returns the content type of the response body. This is based on the
   * response.content.mimeType property. If this value is not available, then
   * the content type is guessed by the file extension of the request URL.
   *
   * @return string
   *         Content type or empty string if no content type could be figured
   *         out.
   */
  get contentType()
  {
    if (this._contentType) {
      return this._contentType;
    }

    let request = this.httpActivity.request;
    let response = this.httpActivity.response;

    let contentType = "";
    let types = response.content ?
                (response.content.mimeType || "").split(/,|;/) : [];
    for (let i = 0; i < types.length; i++) {
      if (types[i] in NetworkHelper.mimeCategoryMap) {
        contentType = types[i];
        break;
      }
    }

    if (contentType) {
      this._contentType = contentType;
      return contentType;
    }

    // Try to get the content type from the request file extension.
    let uri = NetUtil.newURI(request.url);
    if ((uri instanceof Ci.nsIURL) && uri.fileExtension) {
      try {
         contentType = mimeService.getTypeFromExtension(uri.fileExtension);
      }
      catch(ex) {
        // Added to prevent failures on OS X 64. No Flash?
        Cu.reportError(ex);
      }
    }

    this._contentType = contentType;
    return contentType;
  },

  /**
   *
   * @returns boolean
   *          True if the response is an image, false otherwise.
   */
  get _responseIsImage()
  {
    return this.contentType &&
           NetworkHelper.mimeCategoryMap[this.contentType] == "image";
  },

  /**
   *
   * @returns boolean
   *          True if the response body contains text, false otherwise.
   */
  get _isResponseBodyTextData()
  {
    return this.contentType ?
           NetworkHelper.isTextMimeType(this.contentType) : false;
  },

  /**
   * Tells if the server response is cached.
   *
   * @returns boolean
   *          Returns true if the server responded that the request is already
   *          in the browser's cache, false otherwise.
   */
  get _isResponseCached()
  {
    return this.httpActivity.response.status == 304;
  },

  /**
   * Tells if the request body includes form data.
   *
   * @returns boolean
   *          Returns true if the posted body contains form data.
   */
  get _isRequestBodyFormData()
  {
    let requestBody = this.httpActivity.request.postData.text;
    if (typeof requestBody == "object" && requestBody.type == "longString") {
      requestBody = requestBody.initial;
    }
    return this._fromDataRegExp.test(requestBody);
  },

  /**
   * Appends the node with id=aId by the text aValue.
   *
   * @private
   * @param string aId
   * @param string aValue
   * @return nsIDOMElement
   *         The DOM element with id=aId.
   */
  _appendTextNode: function NP__appendTextNode(aId, aValue)
  {
    let textNode = this.document.createTextNode(aValue);
    let elem = this.document.getElementById(aId);
    elem.appendChild(textNode);
    return elem;
  },

  /**
   * Generates some HTML to display the key-value pair of the aList data. The
   * generated HTML is added to node with id=aParentId.
   *
   * @param string aParentId
   *        Id of the parent node to append the list to.
   * @oaram array aList
   *        Array that holds the objects you want to display. Each object must
   *        have two properties: name and value.
   * @param boolean aIgnoreCookie
   *        If true, the key-value named "Cookie" is not added to the list.
   * @returns void
   */
  _appendList: function NP_appendList(aParentId, aList, aIgnoreCookie)
  {
    let parent = this.document.getElementById(aParentId);
    let doc = this.document;

    aList.sort(function(a, b) {
      return a.name.toLowerCase() < b.name.toLowerCase();
    });

    aList.forEach((aItem) => {
      let name = aItem.name;
      if (aIgnoreCookie && (name == "Cookie" || name == "Set-Cookie")) {
        return;
      }

      let value = aItem.value;
      let longString = null;
      if (typeof value == "object" && value.type == "longString") {
        value = value.initial;
        longString = true;
      }

      /**
       * The following code creates the HTML:
       * <tr>
       * <th scope="row" class="property-name">${line}:</th>
       * <td class="property-value">${aList[line]}</td>
       * </tr>
       * and adds it to parent.
       */
      let row = doc.createElement("tr");
      let textNode = doc.createTextNode(name + ":");
      let th = doc.createElement("th");
      th.setAttribute("scope", "row");
      th.setAttribute("class", "property-name");
      th.appendChild(textNode);
      row.appendChild(th);

      textNode = doc.createTextNode(value);
      let td = doc.createElement("td");
      td.setAttribute("class", "property-value");
      td.appendChild(textNode);

      if (longString) {
        let a = doc.createElement("a");
        a.href = "#";
        a.className = "longStringEllipsis";
        a.addEventListener("mousedown", this._longStringClick.bind(this, aItem));
        a.textContent = l10n.getStr("longStringEllipsis");
        td.appendChild(a);
      }

      row.appendChild(td);

      parent.appendChild(row);
    });
  },

  /**
   * The click event handler for the ellipsis which allows the user to retrieve
   * the full header value.
   *
   * @private
   * @param object aHeader
   *        The header object with the |name| and |value| properties.
   * @param nsIDOMEvent aEvent
   *        The DOM click event object.
   */
  _longStringClick: function NP__longStringClick(aHeader, aEvent)
  {
    aEvent.preventDefault();

    let longString = this.webconsole.webConsoleClient.longString(aHeader.value);

    longString.substring(longString.initial.length, longString.length,
      function NP__onLongStringSubstring(aResponse)
      {
        if (aResponse.error) {
          Cu.reportError("NP__onLongStringSubstring error: " + aResponse.error);
          return;
        }

        aHeader.value = aHeader.value.initial + aResponse.substring;

        let textNode = aEvent.target.previousSibling;
        textNode.textContent += aResponse.substring;
        textNode.parentNode.removeChild(aEvent.target);
      });
  },

  /**
   * Displays the node with id=aId.
   *
   * @private
   * @param string aId
   * @return nsIDOMElement
   *         The element with id=aId.
   */
  _displayNode: function NP__displayNode(aId)
  {
    let elem = this.document.getElementById(aId);
    elem.style.display = "block";
  },

  /**
   * Sets the request URL, request method, the timing information when the
   * request started and the request header content on the NetworkPanel.
   * If the request header contains cookie data, a list of sent cookies is
   * generated and a special sent cookie section is displayed + the cookie list
   * added to it.
   *
   * @returns void
   */
  _displayRequestHeader: function NP__displayRequestHeader()
  {
    let request = this.httpActivity.request;
    let requestTime = new Date(this.httpActivity.startedDateTime);

    this._appendTextNode("headUrl", request.url);
    this._appendTextNode("headMethod", request.method);
    this._appendTextNode("requestHeadersInfo",
                         l10n.timestampString(requestTime));

    this._appendList("requestHeadersContent", request.headers, true);

    if (request.cookies.length > 0) {
      this._displayNode("requestCookie");
      this._appendList("requestCookieContent", request.cookies);
    }
  },

  /**
   * Displays the request body section of the NetworkPanel and set the request
   * body content on the NetworkPanel.
   *
   * @returns void
   */
  _displayRequestBody: function NP__displayRequestBody()
  {
    let postData = this.httpActivity.request.postData;
    this._displayNode("requestBody");
    this._appendTextNode("requestBodyContent", postData.text);
  },

  /*
   * Displays the `sent form data` section. Parses the request header for the
   * submitted form data displays it inside of the `sent form data` section.
   *
   * @returns void
   */
  _displayRequestForm: function NP__processRequestForm()
  {
    let postData = this.httpActivity.request.postData.text;
    let requestBodyLines = postData.split("\n");
    let formData = requestBodyLines[requestBodyLines.length - 1].
                      replace(/\+/g, " ").split("&");

    function unescapeText(aText)
    {
      try {
        return decodeURIComponent(aText);
      }
      catch (ex) {
        return decodeURIComponent(unescape(aText));
      }
    }

    let formDataArray = [];
    for (let i = 0; i < formData.length; i++) {
      let data = formData[i];
      let idx = data.indexOf("=");
      let key = data.substring(0, idx);
      let value = data.substring(idx + 1);
      formDataArray.push({
        name: unescapeText(key),
        value: unescapeText(value)
      });
    }

    this._appendList("requestFormDataContent", formDataArray);
    this._displayNode("requestFormData");
  },

  /**
   * Displays the response section of the NetworkPanel, sets the response status,
   * the duration between the start of the request and the receiving of the
   * response header as well as the response header content on the the NetworkPanel.
   *
   * @returns void
   */
  _displayResponseHeader: function NP__displayResponseHeader()
  {
    let timing = this.httpActivity.timings;
    let response = this.httpActivity.response;

    this._appendTextNode("headStatus",
                         [response.httpVersion, response.status,
                          response.statusText].join(" "));

    // Calculate how much time it took from the request start, until the
    // response started to be received.
    let deltaDuration = 0;
    ["dns", "connect", "send", "wait"].forEach(function (aValue) {
      let ms = timing[aValue];
      if (ms > -1) {
        deltaDuration += ms;
      }
    });

    this._appendTextNode("responseHeadersInfo",
      this._format("durationMS", [deltaDuration]));

    this._displayNode("responseContainer");
    this._appendList("responseHeadersContent", response.headers, true);

    if (response.cookies.length > 0) {
      this._displayNode("responseCookie");
      this._appendList("responseCookieContent", response.cookies);
    }
  },

  /**
   * Displays the respones image section, sets the source of the image displayed
   * in the image response section to the request URL and the duration between
   * the receiving of the response header and the end of the request. Once the
   * image is loaded, the size of the requested image is set.
   *
   * @returns void
   */
  _displayResponseImage: function NP__displayResponseImage()
  {
    let self = this;
    let timing = this.httpActivity.timings;
    let request = this.httpActivity.request;
    let response = this.httpActivity.response;
    let cached = "";

    if (this._isResponseCached) {
      cached = "Cached";
    }

    let imageNode = this.document.getElementById("responseImage" +
                                                 cached + "Node");

    let text = response.content.text;
    if (typeof text == "object" && text.type == "longString") {
      this._showResponseBodyFetchLink();
    }
    else {
      imageNode.setAttribute("src",
        "data:" + this.contentType + ";base64," + text);
    }

    // This function is called to set the imageInfo.
    function setImageInfo() {
      self._appendTextNode("responseImage" + cached + "Info",
        self._format("imageSizeDeltaDurationMS",
          [ imageNode.width, imageNode.height, timing.receive ]
        )
      );
    }

    // Check if the image is already loaded.
    if (imageNode.width != 0) {
      setImageInfo();
    }
    else {
      // Image is not loaded yet therefore add a load event.
      imageNode.addEventListener("load", function imageNodeLoad() {
        imageNode.removeEventListener("load", imageNodeLoad, false);
        setImageInfo();
      }, false);
    }

    this._displayNode("responseImage" + cached);
  },

  /**
   * Displays the response body section, sets the the duration between
   * the receiving of the response header and the end of the request as well as
   * the content of the response body on the NetworkPanel.
   *
   * @returns void
   */
  _displayResponseBody: function NP__displayResponseBody()
  {
    let timing = this.httpActivity.timings;
    let response = this.httpActivity.response;
    let cached =  this._isResponseCached ? "Cached" : "";

    this._appendTextNode("responseBody" + cached + "Info",
      this._format("durationMS", [timing.receive]));

    this._displayNode("responseBody" + cached);

    let text = response.content.text;
    if (typeof text == "object") {
      text = text.initial;
      this._showResponseBodyFetchLink();
    }

    this._appendTextNode("responseBody" + cached + "Content", text);
  },

  /**
   * Show the "fetch response body" link.
   * @private
   */
  _showResponseBodyFetchLink: function NP__showResponseBodyFetchLink()
  {
    let content = this.httpActivity.response.content;

    let elem = this._appendTextNode("responseBodyFetchLink",
      this._format("fetchRemainingResponseContentLink",
                   [content.text.length - content.text.initial.length]));

    elem.style.display = "block";
    elem.addEventListener("mousedown", this._responseBodyFetch);
  },

  /**
   * Click event handler for the link that allows users to fetch the remaining
   * response body.
   *
   * @private
   * @param nsIDOMEvent aEvent
   */
  _responseBodyFetch: function NP__responseBodyFetch(aEvent)
  {
    aEvent.target.style.display = "none";
    aEvent.target.removeEventListener("mousedown", this._responseBodyFetch);

    let content = this.httpActivity.response.content;
    let longString = this.webconsole.webConsoleClient.longString(content.text);
    longString.substring(longString.initial.length, longString.length,
      (aResponse) =>
      {
        if (aResponse.error) {
          Cu.reportError("NP__onLongStringSubstring error: " + aResponse.error);
          return;
        }

        content.text = content.text.initial + aResponse.substring;
        let cached =  this._isResponseCached ? "Cached" : "";

        if (this._responseIsImage) {
          let imageNode = this.document.getElementById("responseImage" +
                                                       cached + "Node");
          imageNode.src =
            "data:" + this.contentType + ";base64," + content.text;
        }
        else {
          this._appendTextNode("responseBody" + cached + "Content",
                               aResponse.substring);
        }
      });
  },

  /**
   * Displays the `Unknown Content-Type hint` and sets the duration between the
   * receiving of the response header on the NetworkPanel.
   *
   * @returns void
   */
  _displayResponseBodyUnknownType: function NP__displayResponseBodyUnknownType()
  {
    let timing = this.httpActivity.timings;

    this._displayNode("responseBodyUnknownType");
    this._appendTextNode("responseBodyUnknownTypeInfo",
      this._format("durationMS", [timing.receive]));

    this._appendTextNode("responseBodyUnknownTypeContent",
      this._format("responseBodyUnableToDisplay.content", [this.contentType]));
  },

  /**
   * Displays the `no response body` section and sets the the duration between
   * the receiving of the response header and the end of the request.
   *
   * @returns void
   */
  _displayNoResponseBody: function NP_displayNoResponseBody()
  {
    let timing = this.httpActivity.timings;

    this._displayNode("responseNoBody");
    this._appendTextNode("responseNoBodyInfo",
      this._format("durationMS", [timing.receive]));
  },

  /**
   * Updates the content of the NetworkPanel's iframe.
   *
   * @returns void
   */
  update: function NP_update()
  {
    if (!this.document || this.document.readyState != "complete") {
      return;
    }

    let updates = this.httpActivity.updates;
    let timing = this.httpActivity.timings;
    let request = this.httpActivity.request;
    let response = this.httpActivity.response;

    switch (this._state) {
      case this._INIT:
        this._displayRequestHeader();
        this._state = this._DISPLAYED_REQUEST_HEADER;
        // FALL THROUGH

      case this._DISPLAYED_REQUEST_HEADER:
        // Process the request body if there is one.
        if (!this.httpActivity.discardRequestBody && request.postData.text) {
          this._updateRequestBody();
          this._state = this._DISPLAYED_REQUEST_BODY;
        }
        // FALL THROUGH

      case this._DISPLAYED_REQUEST_BODY:
        if (!response.headers.length || !Object.keys(timing).length) {
          break;
        }
        this._displayResponseHeader();
        this._state = this._DISPLAYED_RESPONSE_HEADER;
        // FALL THROUGH

      case this._DISPLAYED_RESPONSE_HEADER:
        if (updates.indexOf("responseContent") == -1 ||
            updates.indexOf("eventTimings") == -1) {
          break;
        }

        this._state = this._TRANSITION_CLOSED;
        if (this.httpActivity.discardResponseBody) {
          break;
        }

        if (!response.content || !response.content.text) {
          this._displayNoResponseBody();
        }
        else if (this._responseIsImage) {
          this._displayResponseImage();
        }
        else if (!this._isResponseBodyTextData) {
          this._displayResponseBodyUnknownType();
        }
        else if (response.content.text) {
          this._displayResponseBody();
        }
        break;
    }

    if (this._onUpdate) {
      this._onUpdate();
    }
  },

  /**
   * Update the panel to hold the current information we have about the request
   * body.
   * @private
   */
  _updateRequestBody: function NP__updateRequestBody()
  {
    let postData = this.httpActivity.request.postData;
    if (typeof postData.text == "object" && postData.text.type == "longString") {
      let elem = this._appendTextNode("requestBodyFetchLink",
        this._format("fetchRemainingRequestContentLink",
                     [postData.text.length - postData.text.initial.length]));

      elem.style.display = "block";
      elem.addEventListener("mousedown", this._requestBodyFetch);
      return;
    }

    // Check if we send some form data. If so, display the form data special.
    if (this._isRequestBodyFormData) {
      this._displayRequestForm();
    }
    else {
      this._displayRequestBody();
    }
  },

  /**
   * Click event handler for the link that allows users to fetch the remaining
   * request body.
   *
   * @private
   * @param nsIDOMEvent aEvent
   */
  _requestBodyFetch: function NP__requestBodyFetch(aEvent)
  {
    aEvent.target.style.display = "none";
    aEvent.target.removeEventListener("mousedown", this._responseBodyFetch);

    let postData = this.httpActivity.request.postData;
    let longString = this.webconsole.webConsoleClient.longString(postData.text);
    longString.substring(longString.initial.length, longString.length,
       (aResponse) =>
      {
        if (aResponse.error) {
          Cu.reportError("NP__onLongStringSubstring error: " + aResponse.error);
          return;
        }

        postData.text = postData.text.initial + aResponse.substring;
        this._updateRequestBody();
      });
  },
};

/**
 * Creates a DOMNode and sets all the attributes of aAttributes on the created
 * element.
 *
 * @param nsIDOMDocument aDocument
 *        Document to create the new DOMNode.
 * @param string aTag
 *        Name of the tag for the DOMNode.
 * @param object aAttributes
 *        Attributes set on the created DOMNode.
 *
 * @returns nsIDOMNode
 */
function createElement(aDocument, aTag, aAttributes)
{
  let node = aDocument.createElement(aTag);
  if (aAttributes) {
    for (let attr in aAttributes) {
      node.setAttribute(attr, aAttributes[attr]);
    }
  }
  return node;
}

/**
 * Creates a new DOMNode and appends it to aParent.
 *
 * @param nsIDOMNode aParent
 *        A parent node to append the created element.
 * @param string aTag
 *        Name of the tag for the DOMNode.
 * @param object aAttributes
 *        Attributes set on the created DOMNode.
 *
 * @returns nsIDOMNode
 */
function createAndAppendElement(aParent, aTag, aAttributes)
{
  let node = createElement(aParent.ownerDocument, aTag, aAttributes);
  aParent.appendChild(node);
  return node;
}
