/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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, components} = require("chrome");
const Services = require("Services");
const {Class} = require("sdk/core/heritage");
const {Unknown} = require("sdk/platform/xpcom");
const xpcom = require("sdk/platform/xpcom");
const Events = require("sdk/dom/events");
const Clipboard = require("sdk/clipboard");

loader.lazyRequireGetter(this, "NetworkHelper",
                               "devtools/shared/webconsole/network-helper");
loader.lazyRequireGetter(this, "JsonViewUtils",
                               "devtools/client/jsonview/utils");

const childProcessMessageManager =
  Cc["@mozilla.org/childprocessmessagemanager;1"]
    .getService(Ci.nsISyncMessageSender);

const JSON_VIEW_MIME_TYPE = "application/vnd.mozilla.json.view";
const CONTRACT_ID = "@mozilla.org/streamconv;1?from=" +
  JSON_VIEW_MIME_TYPE + "&to=*/*";
const CLASS_ID = "{d8c9acee-dec5-11e4-8c75-1681e6b88ec1}";

// Localization
let jsonViewStrings = 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/
 */
let Converter = Class({
  extends: Unknown,

  interfaces: [
    "nsIStreamConverter",
    "nsIStreamListener",
    "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 spits it back to the listener
   * 4. onStopRequest spits it 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) {
    this.listener.onDataAvailable(...arguments);
  },

  onStartRequest: function (request, context) {
    // Set the content type to HTML in order to parse the doctype, styles
    // and scripts, but later a <plaintext> element will switch the tokenizer
    // to the plaintext state in order to parse the JSON.
    request.QueryInterface(Ci.nsIChannel);
    request.contentType = "text/html";

    // JSON enforces UTF-8 charset (see bug 741776).
    request.contentCharset = "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.resetPrincipalsToNullPrincipal();

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

    // Initialize stuff.
    let win = NetworkHelper.getWindowForRequest(request);
    exportData(win, request);
    win.addEventListener("DOMContentLoaded", event => {
      win.addEventListener("contentMessage", onContentMessage, false, true);
    }, {once: true});

    // Insert the initial HTML code.
    let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
                      .createInstance(Ci.nsIScriptableUnicodeConverter);
    converter.charset = "UTF-8";
    let stream = converter.convertToInputStream(initialHTML(win.document));
    this.listener.onDataAvailable(request, context, stream, 0, stream.available());
  },

  onStopRequest: function (request, context, statusCode) {
    this.listener.onStopRequest(request, context, statusCode);
    this.listener = null;
  }
});

// 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 originalType;
  if (request instanceof Ci.nsIHttpChannel) {
    try {
      let header = request.getResponseHeader("Content-Type");
      originalType = header.split(";")[0];
    } catch (err) {
      // Handled below
    }
  } else {
    let uri = request.QueryInterface(Ci.nsIChannel).URI.spec;
    let match = uri.match(/^data:(.*?)[,;]/);
    if (match) {
      originalType = match[1];
    }
  }
  const JSON_TYPES = ["application/json", "application/manifest+json"];
  if (!JSON_TYPES.includes(originalType)) {
    originalType = JSON_TYPES[0];
  }
  request.QueryInterface(Ci.nsIWritablePropertyBag);
  request.setProperty("contentType", originalType);
}

// Exports variables that will be accessed by the non-privileged scripts.
function exportData(win, request) {
  let Locale = {
    $STR: key => {
      try {
        return jsonViewStrings.GetStringFromName(key);
      } catch (err) {
        console.error(err);
        return undefined;
      }
    }
  };
  JsonViewUtils.exportIntoContentScope(win, Locale, "Locale");

  let 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});
      }
    });
  }
  JsonViewUtils.exportIntoContentScope(win, headers, "headers");
}

// Serializes a qualifiedName and an optional set of attributes into an HTML
// start tag. Be aware qualifiedName and attribute names are not validated.
// Attribute values are escaped with escapingString algorithm in attribute mode
// (https://html.spec.whatwg.org/multipage/syntax.html#escapingString).
function startTag(qualifiedName, attributes = {}) {
  return Object.entries(attributes).reduce(function (prev, [attr, value]) {
    return prev + " " + attr + "=\"" +
      value.replace(/&/g, "&amp;")
           .replace(/\u00a0/g, "&nbsp;")
           .replace(/"/g, "&quot;") +
      "\"";
  }, "<" + qualifiedName) + ">";
}

// Builds an HTML string that will be used to load stylesheets and scripts,
// and switch the parser to plaintext state.
function initialHTML(doc) {
  let os;
  let platform = Services.appinfo.OS;
  if (platform.startsWith("WINNT")) {
    os = "win";
  } else if (platform.startsWith("Darwin")) {
    os = "mac";
  } else {
    os = "linux";
  }

  let base = doc.createElement("base");
  base.href = "resource://devtools/client/jsonview/";

  let style = doc.createElement("link");
  style.rel = "stylesheet";
  style.type = "text/css";
  style.href = "css/main.css";

  let script = doc.createElement("script");
  script.src = "lib/require.js";
  script.dataset.main = "viewer-config";
  script.defer = true;

  let head = doc.createElement("head");
  head.append(base, style, script);

  return "<!DOCTYPE html>\n" +
    startTag("html", {
      "platform": os,
      "class": "theme-" + JsonViewUtils.getCurrentTheme(),
      "dir": Services.locale.isAppLocaleRTL ? "rtl" : "ltr"
    }) +
    head.outerHTML +
    startTag("body") +
    startTag("div", {"id": "content"}) +
    startTag("plaintext", {"id": "json"});
}

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

  let value = e.detail.value;
  switch (e.detail.type) {
    case "copy":
      copyString(win, value);
      break;

    case "copy-headers":
      copyHeaders(win, value);
      break;

    case "save":
      childProcessMessageManager.sendAsyncMessage(
        "devtools:jsonview:save", value);
  }
}

function copyHeaders(win, headers) {
  let value = "";
  let eol = (Services.appinfo.OS !== "WINNT") ? "\n" : "\r\n";

  let responseHeaders = headers.response;
  for (let i = 0; i < responseHeaders.length; i++) {
    let header = responseHeaders[i];
    value += header.name + ": " + header.value + eol;
  }

  value += eol;

  let requestHeaders = headers.request;
  for (let i = 0; i < requestHeaders.length; i++) {
    let header = requestHeaders[i];
    value += header.name + ": " + header.value + eol;
  }

  copyString(win, value);
}

function copyString(win, string) {
  win.document.addEventListener("copy", event => {
    event.clipboardData.setData("text/plain", string);
    event.preventDefault();
  }, {once: true});

  win.document.execCommand("copy", false, null);
}

// Stream converter component definition
let service = xpcom.Service({
  id: components.ID(CLASS_ID),
  contract: CONTRACT_ID,
  Component: Converter,
  register: false,
  unregister: false
});

function register() {
  if (!xpcom.isRegistered(service)) {
    xpcom.register(service);
    return true;
  }
  return false;
}

function unregister() {
  if (xpcom.isRegistered(service)) {
    xpcom.unregister(service);
    return true;
  }
  return false;
}

exports.JsonViewService = {
  register: register,
  unregister: unregister
};
