/* -*- 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/. */

"use strict";

const {Ci, Cu, CC} = require("chrome");
const ChromeUtils = require("ChromeUtils");
const Services = require("Services");

loader.lazyRequireGetter(this, "NetworkHelper",
                               "devtools/shared/webconsole/network-helper");
loader.lazyGetter(this, "debugJsModules", function() {
  const {AppConstants} = require("resource://gre/modules/AppConstants.jsm");
  return !!(AppConstants.DEBUG_JS_MODULES);
});

const BinaryInput = CC("@mozilla.org/binaryinputstream;1",
                       "nsIBinaryInputStream", "setInputStream");
const BufferStream = CC("@mozilla.org/io/arraybuffer-input-stream;1",
                       "nsIArrayBufferInputStream", "setData");

const kCSP = "default-src 'none' ; script-src resource:; ";

// Localization
loader.lazyGetter(this, "jsonViewStrings", () => {
  return Services.strings.createBundle(
    "chrome://devtools/locale/jsonview.properties");
});

/**
 * This object detects 'application/vnd.mozilla.json.view' content type
 * and converts it into a JSON Viewer application that allows simple
 * JSON inspection.
 *
 * Inspired by JSON View: https://github.com/bhollis/jsonview/
 */
function Converter() {}

Converter.prototype = {
  QueryInterface: ChromeUtils.generateQI([
    Ci.nsIStreamConverter,
    Ci.nsIStreamListener,
    Ci.nsIRequestObserver
  ]),

  get wrappedJSObject() {
    return this;
  },

  /**
   * This component works as such:
   * 1. asyncConvertData captures the listener
   * 2. onStartRequest fires, initializes stuff, modifies the listener
   *    to match our output type
   * 3. onDataAvailable decodes and inserts data into a text node
   * 4. onStopRequest flushes data and spits back to the listener
   * 5. convert does nothing, it's just the synchronous version
   *    of asyncConvertData
   */
  convert: function(fromStream, fromType, toType, ctx) {
    return fromStream;
  },

  asyncConvertData: function(fromType, toType, listener, ctx) {
    this.listener = listener;
  },

  onDataAvailable: function(request, context, inputStream, offset, count) {
    // Decode and insert data.
    const buffer = new ArrayBuffer(count);
    new BinaryInput(inputStream).readArrayBuffer(count, buffer);
    this.decodeAndInsertBuffer(buffer);
  },

  onStartRequest: function(request, context) {
    // Set the content type to HTML in order to parse the doctype, styles
    // and scripts. The JSON will be manually inserted as text.
    request.QueryInterface(Ci.nsIChannel);
    request.contentType = "text/html";

    const headers = getHttpHeaders(request);

    // Enforce strict CSP:
    try {
      request.QueryInterface(Ci.nsIHttpChannel);
      request.setResponseHeader("Content-Security-Policy", kCSP, false);
      request.setResponseHeader("Content-Security-Policy-Report-Only", "", false);
    } catch (ex) {
      // If this is not an HTTP channel we can't and won't do anything.
    }

    // Don't honor the charset parameter and use UTF-8 (see bug 741776).
    request.contentCharset = "UTF-8";
    this.decoder = new TextDecoder("UTF-8");

    // Changing the content type breaks saving functionality. Fix it.
    fixSave(request);

    // Because content might still have a reference to this window,
    // force setting it to a null principal to avoid it being same-
    // origin with (other) content.
    request.loadInfo.resetPrincipalToInheritToNullPrincipal();

    // Start the request.
    this.listener.onStartRequest(request, context);

    // Initialize stuff.
    const win = NetworkHelper.getWindowForRequest(request);
    this.data = exportData(win, headers);
    insertJsonData(win, this.data.json);
    win.addEventListener("contentMessage", onContentMessage, false, true);
    keepThemeUpdated(win);

    // Send the initial HTML code.
    const buffer = new TextEncoder().encode(initialHTML(win.document)).buffer;
    const stream = new BufferStream(buffer, 0, buffer.byteLength);
    this.listener.onDataAvailable(request, context, stream, 0, stream.available());
  },

  onStopRequest: function(request, context, statusCode) {
    // Flush data.
    this.decodeAndInsertBuffer(new ArrayBuffer(0), true);

    // Stop the request.
    this.listener.onStopRequest(request, context, statusCode);
    this.listener = null;
    this.decoder = null;
    this.data = null;
  },

  // Decodes an ArrayBuffer into a string and inserts it into the page.
  decodeAndInsertBuffer: function(buffer, flush = false) {
    // Decode the buffer into a string.
    const data = this.decoder.decode(buffer, {stream: !flush});

    // Using `appendData` instead of `textContent +=` is important to avoid
    // repainting previous data.
    this.data.json.appendData(data);
  }
};

// Lets "save as" save the original JSON, not the viewer.
// To save with the proper extension we need the original content type,
// which has been replaced by application/vnd.mozilla.json.view
function fixSave(request) {
  let match;
  if (request instanceof Ci.nsIHttpChannel) {
    try {
      const header = request.getResponseHeader("Content-Type");
      match = header.match(/^(application\/(?:[^;]+\+)?json)(?:;|$)/);
    } catch (err) {
      // Handled below
    }
  } else {
    const uri = request.QueryInterface(Ci.nsIChannel).URI.spec;
    match = uri.match(/^data:(application\/(?:[^;,]+\+)?json)[;,]/);
  }
  let originalType;
  if (match) {
    originalType = match[1];
  } else {
    originalType = "application/json";
  }
  request.QueryInterface(Ci.nsIWritablePropertyBag);
  request.setProperty("contentType", originalType);
}

function getHttpHeaders(request) {
  const headers = {
    response: [],
    request: []
  };
  // The request doesn't have to be always nsIHttpChannel
  // (e.g. in case of data: URLs)
  if (request instanceof Ci.nsIHttpChannel) {
    request.visitResponseHeaders({
      visitHeader: function(name, value) {
        headers.response.push({name: name, value: value});
      }
    });
    request.visitRequestHeaders({
      visitHeader: function(name, value) {
        headers.request.push({name: name, value: value});
      }
    });
  }
  return headers;
}

// Exports variables that will be accessed by the non-privileged scripts.
function exportData(win, headers) {
  const json = new win.Text();
  const JSONView = Cu.cloneInto({
    debugJsModules,
    headers,
    json,
    readyState: "uninitialized",
    Locale: {
      $STR: key => {
        try {
          return jsonViewStrings.GetStringFromName(key);
        } catch (err) {
          console.error(err);
          return undefined;
        }
      }
    },
  }, win, {
    cloneFunctions: true,
    wrapReflectors: true,
  });
  try {
    Object.defineProperty(Cu.waiveXrays(win), "JSONView", {
      value: JSONView,
      configurable: true,
      enumerable: true,
      writable: true,
    });
  } catch (error) {
    Cu.reportError(error);
  }
  return {json};
}

// Builds an HTML string that will be used to load stylesheets and scripts.
function initialHTML(doc) {
  // Creates an element with the specified type, attributes and children.
  function element(type, attributes = {}, children = []) {
    const el = doc.createElement(type);
    for (const [attr, value] of Object.entries(attributes)) {
      el.setAttribute(attr, value);
    }
    el.append(...children);
    return el;
  }

  let os;
  const platform = Services.appinfo.OS;
  if (platform.startsWith("WINNT")) {
    os = "win";
  } else if (platform.startsWith("Darwin")) {
    os = "mac";
  } else {
    os = "linux";
  }

  const baseURI = "resource://devtools-client-jsonview/";

  return "<!DOCTYPE html>\n" +
    element("html", {
      "platform": os,
      "class": "theme-" + Services.prefs.getCharPref("devtools.theme"),
      "dir": Services.locale.isAppLocaleRTL ? "rtl" : "ltr"
    }, [
      element("head", {}, [
        element("meta", {
          "http-equiv": "Content-Security-Policy",
          content: kCSP,
        }),
        element("link", {
          rel: "stylesheet",
          type: "text/css",
          href: baseURI + "css/main.css",
        }),
      ]),
      element("body", {}, [
        element("div", {"id": "content"}, [
          element("div", {"id": "json"})
        ]),
        element("script", {
          src: baseURI + "lib/require.js",
          "data-main": baseURI + "viewer-config.js",
        }),
      ]),
    ]).outerHTML;
}

// We insert the received data into a text node, which should be appended into
// the #json element so that the JSON is still displayed even if JS is disabled.
// However, the HTML parser is not synchronous, so this function uses a mutation
// observer to detect the creation of the element. Then the text node is appended.
function insertJsonData(win, json) {
  new win.MutationObserver(function(mutations, observer) {
    for (const {target, addedNodes} of mutations) {
      if (target.nodeType == 1 && target.id == "content") {
        for (const node of addedNodes) {
          if (node.nodeType == 1 && node.id == "json") {
            observer.disconnect();
            node.append(json);
            return;
          }
        }
      }
    }
  }).observe(win.document, {
    childList: true,
    subtree: true,
  });
}

function keepThemeUpdated(win) {
  const listener = function() {
    const theme = Services.prefs.getCharPref("devtools.theme");
    win.document.documentElement.className = "theme-" + theme;
  };
  Services.prefs.addObserver("devtools.theme", listener);
  win.addEventListener("unload", function(event) {
    Services.prefs.removeObserver("devtools.theme", listener);
    win = null;
  }, {once: true});
}

// Chrome <-> Content communication
function onContentMessage(e) {
  // Do not handle events from different documents.
  const win = this;
  if (win != e.target) {
    return;
  }

  const value = e.detail.value;
  switch (e.detail.type) {
    case "save":
      Services.cpmm.sendAsyncMessage(
        "devtools:jsonview:save", value);
  }
}

function createInstance() {
  return new Converter();
}

exports.JsonViewService = {
  createInstance: createInstance,
};
