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

// React
const React = require("devtools/client/shared/vendor/react");
const ReactDOM = require("devtools/client/shared/vendor/react-dom");

// Reps
const { parseURLParams } = require("devtools/client/shared/components/reps/rep-utils");

// Network
const { cancelEvent, isLeftClick } = require("./utils/events");
const NetInfoBody = React.createFactory(require("./components/net-info-body"));
const DataProvider = require("./data-provider");

// Constants
const XHTML_NS = "http://www.w3.org/1999/xhtml";

/**
 * This object represents a network log in the Console panel (and in the
 * Network panel in the future).
 * It's associated with an existing log and so, also with an existing
 * element in the DOM.
 *
 * The object neither render no request for more data by default. It only
 * reqisters a click listener to the associated log entry (a network event)
 * and changes the class attribute of the log entry, so a twisty icon
 * appears to indicates that there are more details displayed if the
 * log entry is expanded.
 *
 * When the user expands the log, data are requested from the backend
 * and rendered directly within the Console iframe.
 */
function NetRequest(log) {
  this.initialize(log);
}

NetRequest.prototype = {
  initialize: function (log) {
    this.client = log.consoleFrame.webConsoleClient;
    this.owner = log.consoleFrame.owner;

    // 'this.file' field is following HAR spec.
    // http://www.softwareishard.com/blog/har-12-spec/
    this.file = log.response;
    this.parentNode = log.node;
    this.file.request.queryString = parseURLParams(this.file.request.url);
    this.hasCookies = false;

    // Map of fetched responses (to avoid unnecessary RDP round trip).
    this.cachedResponses = new Map();

    let doc = this.parentNode.ownerDocument;
    let twisty = doc.createElementNS(XHTML_NS, "a");
    twisty.className = "theme-twisty";
    twisty.href = "#";

    let messageBody = this.parentNode.querySelector(".message-body-wrapper");
    this.parentNode.insertBefore(twisty, messageBody);
    this.parentNode.setAttribute("collapsible", true);

    this.parentNode.classList.add("netRequest");

    // Register a click listener.
    this.addClickListener();
  },

  addClickListener: function () {
    // Add an event listener to toggle the expanded state when clicked.
    // The event bubbling is canceled if the user clicks on the log
    // itself (not on the expanded body), so opening of the default
    // modal dialog is avoided.
    this.parentNode.addEventListener("click", (event) => {
      if (!isLeftClick(event)) {
        return;
      }

      // Clicking on the toggle button or the method expands/collapses
      // the body with HTTP details.
      let classList = event.originalTarget.classList;
      if (!(classList.contains("theme-twisty") ||
        classList.contains("method"))) {
        return;
      }

      // Alright, the user is clicking fine, let's open HTTP details!
      this.onToggleBody(event);

      // Avoid the default modal dialog
      cancelEvent(event);
    }, true);
  },

  onToggleBody: function (event) {
    let target = event.currentTarget;
    let logRow = target.closest(".netRequest");
    logRow.classList.toggle("opened");

    let twisty = this.parentNode.querySelector(".theme-twisty");
    if (logRow.classList.contains("opened")) {
      twisty.setAttribute("open", true);
    } else {
      twisty.removeAttribute("open");
    }

    let isOpen = logRow.classList.contains("opened");
    if (isOpen) {
      this.renderBody();
    } else {
      this.closeBody();
    }
  },

  updateCookies: function(method, response) {
    // TODO: This code will be part of a reducer.
    let result;
    if (response.cookies > 0 &&
        ["requestCookies", "responseCookies"].includes(method)) {
      this.hasCookies = true;
      this.refresh();
    }
  },

  /**
   * Executed when 'networkEventUpdate' is received from the backend.
   */
  updateBody: function (response) {
    // 'networkEventUpdate' event indicates that there are new data
    // available on the backend. The following logic checks the response
    // cache and if this data has been already requested before they
    // need to be updated now (re-requested).
    let method = response.updateType;
    this.updateCookies(method, response);
    if (this.cachedResponses.get(method)) {
      this.cachedResponses.delete(method);
      this.requestData(method);
    }
  },

  /**
   * Close network inline preview body.
   */
  closeBody: function () {
    this.netInfoBodyBox.parentNode.removeChild(this.netInfoBodyBox);
  },

  /**
   * Render network inline preview body.
   */
  renderBody: function () {
    let messageBody = this.parentNode.querySelector(".message-body-wrapper");

    // Create box for all markup rendered by ReactJS. Since we are
    // rendering within webconsole.xul (i.e. XUL document) we need
    // to explicitly specify XHTML namespace.
    let doc = messageBody.ownerDocument;
    this.netInfoBodyBox = doc.createElementNS(XHTML_NS, "div");
    this.netInfoBodyBox.classList.add("netInfoBody");
    messageBody.appendChild(this.netInfoBodyBox);

    // As soon as Redux is in place state and actions will come from
    // separate modules.
    let body = NetInfoBody({
      actions: this
    });

    // Render net info body!
    this.body = ReactDOM.render(body, this.netInfoBodyBox);

    this.refresh();
  },

  /**
   * Render top level ReactJS component.
   */
  refresh: function () {
    if (!this.netInfoBodyBox) {
      return;
    }

    // TODO: As soon as Redux is in place there will be reducer
    // computing a new state.
    let newState = Object.assign({}, this.body.state, {
      data: this.file,
      hasCookies: this.hasCookies
    });

    this.body.setState(newState);
  },

  // Communication with the backend

  requestData: function (method) {
    // If the response has already been received bail out.
    let response = this.cachedResponses.get(method);
    if (response) {
      return;
    }

    // Set an attribute indicating that this net log is waiting for
    // data coming from the backend. Intended mainly for tests.
    this.parentNode.setAttribute("loading", "true");

    let actor = this.file.actor;
    DataProvider.requestData(this.client, actor, method).then(args => {
      this.cachedResponses.set(method, args);
      this.onRequestData(method, args);

      if (!DataProvider.hasPendingRequests()) {
        this.parentNode.removeAttribute("loading");

        // Fire an event indicating that all pending requests for
        // data from the backend has finished. Intended for tests.
        // Do it asynchronously so, it's done after all handlers
        // for the current promise are executed.
        setTimeout(() => {
          let event = document.createEvent("Event");
          event.initEvent("netlog-no-pending-requests", true, true);
          this.parentNode.dispatchEvent(event);
        });
      }
    });
  },

  onRequestData: function (method, response) {
    // TODO: This code will be part of a reducer.
    let result;
    switch (method) {
      case "requestHeaders":
        result = this.onRequestHeaders(response);
        break;
      case "responseHeaders":
        result = this.onResponseHeaders(response);
        break;
      case "requestCookies":
        result = this.onRequestCookies(response);
        break;
      case "responseCookies":
        result = this.onResponseCookies(response);
        break;
      case "responseContent":
        result = this.onResponseContent(response);
        break;
      case "requestPostData":
        result = this.onRequestPostData(response);
        break;
    }

    result.then(() => {
      this.refresh();
    });
  },

  onRequestHeaders: function (response) {
    this.file.request.headers = response.headers;

    return this.resolveHeaders(this.file.request.headers);
  },

  onResponseHeaders: function (response) {
    this.file.response.headers = response.headers;

    return this.resolveHeaders(this.file.response.headers);
  },

  onResponseContent: function (response) {
    let content = response.content;

    for (let p in content) {
      this.file.response.content[p] = content[p];
    }

    return Promise.resolve();
  },

  onRequestPostData: function (response) {
    this.file.request.postData = response.postData;
    return Promise.resolve();
  },

  onRequestCookies: function (response) {
    this.file.request.cookies = response.cookies;
    return this.resolveHeaders(this.file.request.cookies);
  },

  onResponseCookies: function (response) {
    this.file.response.cookies = response.cookies;
    return this.resolveHeaders(this.file.response.cookies);
  },

  onViewSourceInDebugger: function (frame) {
    this.owner.viewSourceInDebugger(frame.source, frame.line);
  },

  resolveHeaders: function (headers) {
    let promises = [];

    for (let header of headers) {
      if (typeof header.value == "object") {
        promises.push(this.resolveString(header.value).then(value => {
          header.value = value;
        }));
      }
    }

    return Promise.all(promises);
  },

  resolveString: function (object, propName) {
    let stringGrip = object[propName];
    if (typeof stringGrip == "object") {
      DataProvider.resolveString(this.client, stringGrip).then(args => {
        object[propName] = args;
        this.refresh();
      });
    }
  }
};

// Exports from this module
module.exports = NetRequest;
