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

var Ci = Components.interfaces;
var Cc = Components.classes;
var Cu = Components.utils;

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/TelemetryTimestamps.jsm");
Cu.import("resource://gre/modules/TelemetryController.jsm");
Cu.import("resource://gre/modules/TelemetrySession.jsm");
Cu.import("resource://gre/modules/TelemetryArchive.jsm");
Cu.import("resource://gre/modules/TelemetryUtils.jsm");
Cu.import("resource://gre/modules/TelemetryLog.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Task.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                  "resource://gre/modules/AppConstants.jsm");

const Telemetry = Services.telemetry;
const bundle = Services.strings.createBundle(
  "chrome://global/locale/aboutTelemetry.properties");
const brandBundle = Services.strings.createBundle(
  "chrome://branding/locale/brand.properties");

// Maximum height of a histogram bar (in em for html, in chars for text)
const MAX_BAR_HEIGHT = 18;
const MAX_BAR_CHARS = 25;
const PREF_TELEMETRY_SERVER_OWNER = "toolkit.telemetry.server_owner";
const PREF_TELEMETRY_ENABLED = "toolkit.telemetry.enabled";
const PREF_DEBUG_SLOW_SQL = "toolkit.telemetry.debugSlowSql";
const PREF_SYMBOL_SERVER_URI = "profiler.symbolicationUrl";
const DEFAULT_SYMBOL_SERVER_URI = "http://symbolapi.mozilla.org";
const PREF_FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";

// ms idle before applying the filter (allow uninterrupted typing)
const FILTER_IDLE_TIMEOUT = 500;

const isWindows = (Services.appinfo.OS == "WINNT");
const EOL = isWindows ? "\r\n" : "\n";

// This is the ping object currently displayed in the page.
var gPingData = null;

// Cached value of document's RTL mode
var documentRTLMode = "";

/**
 * Helper function for determining whether the document direction is RTL.
 * Caches result of check on first invocation.
 */
function isRTL() {
  if (!documentRTLMode)
    documentRTLMode = window.getComputedStyle(document.body).direction;
  return (documentRTLMode == "rtl");
}

function isArray(arg) {
  return Object.prototype.toString.call(arg) === '[object Array]';
}

function isFlatArray(obj) {
  if (!isArray(obj)) {
    return false;
  }
  return !obj.some(e => typeof(e) == "object");
}

/**
 * This is a helper function for explodeObject.
 */
function flattenObject(obj, map, path, array) {
  for (let k of Object.keys(obj)) {
    let newPath = [...path, array ? "[" + k + "]" : k];
    let v = obj[k];
    if (!v || (typeof(v) != "object")) {
      map.set(newPath.join("."), v);
    } else if (isFlatArray(v)) {
      map.set(newPath.join("."), "[" + v.join(", ") + "]");
    } else {
      flattenObject(v, map, newPath, isArray(v));
    }
  }
}

/**
 * This turns a JSON object into a "flat" stringified form.
 *
 * For an object like {a: "1", b: {c: "2", d: "3"}} it returns a Map of the
 * form Map(["a","1"], ["b.c", "2"], ["b.d", "3"]).
 */
function explodeObject(obj) {
  let map = new Map();
  flattenObject(obj, map, []);
  return map;
}

function filterObject(obj, filterOut) {
  let ret = {};
  for (let k of Object.keys(obj)) {
    if (filterOut.indexOf(k) == -1) {
      ret[k] = obj[k];
    }
  }
  return ret;
}


/**
 * This turns a JSON object into a "flat" stringified form, separated into top-level sections.
 *
 * For an object like:
 *   {
 *     a: {b: "1"},
 *     c: {d: "2", e: {f: "3"}}
 *   }
 * it returns a Map of the form:
 *   Map([
 *     ["a", Map(["b","1"])],
 *     ["c", Map([["d", "2"], ["e.f", "3"]])]
 *   ])
 */
function sectionalizeObject(obj) {
  let map = new Map();
  for (let k of Object.keys(obj)) {
    map.set(k, explodeObject(obj[k]));
  }
  return map;
}

/**
 * Obtain the main DOMWindow for the current context.
 */
function getMainWindow() {
  return window.QueryInterface(Ci.nsIInterfaceRequestor)
               .getInterface(Ci.nsIWebNavigation)
               .QueryInterface(Ci.nsIDocShellTreeItem)
               .rootTreeItem
               .QueryInterface(Ci.nsIInterfaceRequestor)
               .getInterface(Ci.nsIDOMWindow);
}

/**
 * Obtain the DOMWindow that can open a preferences pane.
 *
 * This is essentially "get the browser chrome window" with the added check
 * that the supposed browser chrome window is capable of opening a preferences
 * pane.
 *
 * This may return null if we can't find the browser chrome window.
 */
function getMainWindowWithPreferencesPane() {
  let mainWindow = getMainWindow();
  if (mainWindow && "openAdvancedPreferences" in mainWindow) {
    return mainWindow;
  }
  return null;
}

/**
 * Remove all child nodes of a document node.
 */
function removeAllChildNodes(node) {
  while (node.hasChildNodes()) {
    node.removeChild(node.lastChild);
  }
}

/**
 * Pad a number to two digits with leading "0".
 */
function padToTwoDigits(n) {
  return (n > 9) ? n: "0" + n;
}

/**
 * Return yesterdays date with the same time.
 */
function yesterday(date) {
  let d = new Date(date);
  d.setDate(d.getDate() - 1);
  return d;
}

/**
 * This returns a short date string of the form YYYY/MM/DD.
 */
function shortDateString(date) {
  return date.getFullYear()
         + "/" + padToTwoDigits(date.getMonth() + 1)
         + "/" + padToTwoDigits(date.getDate());
}

/**
 * This returns a short time string of the form hh:mm:ss.
 */
function shortTimeString(date) {
  return padToTwoDigits(date.getHours())
         + ":" + padToTwoDigits(date.getMinutes())
         + ":" + padToTwoDigits(date.getSeconds());
}

var Settings = {
  SETTINGS: [
    // data upload
    {
      pref: PREF_FHR_UPLOAD_ENABLED,
      defaultPrefValue: false,
      descriptionEnabledId: "description-upload-enabled",
      descriptionDisabledId: "description-upload-disabled",
    },
    // extended "Telemetry" recording
    {
      pref: PREF_TELEMETRY_ENABLED,
      defaultPrefValue: false,
      descriptionEnabledId: "description-extended-recording-enabled",
      descriptionDisabledId: "description-extended-recording-disabled",
    },
  ],

  attachObservers: function() {
    for (let s of this.SETTINGS) {
      let setting = s;
      Preferences.observe(setting.pref, this.render, this);
    }

    let elements = document.getElementsByClassName("change-data-choices-link");
    for (let el of elements) {
      el.addEventListener("click", function() {
        if (AppConstants.platform == "android") {
          Cu.import("resource://gre/modules/Messaging.jsm");
          Messaging.sendRequest({
            type: "Settings:Show",
            resource: "preferences_privacy",
          });
        } else {
          // Show the data choices preferences on desktop.
          let mainWindow = getMainWindowWithPreferencesPane();
          mainWindow.openAdvancedPreferences("dataChoicesTab");
        }
      }, false);
    }
  },

  detachObservers: function() {
    for (let setting of this.SETTINGS) {
      Preferences.ignore(setting.pref, this.render, this);
    }
  },

  /**
   * Updates the button & text at the top of the page to reflect Telemetry state.
   */
  render: function() {
    for (let setting of this.SETTINGS) {
      let enabledElement = document.getElementById(setting.descriptionEnabledId);
      let disabledElement = document.getElementById(setting.descriptionDisabledId);

      if (Preferences.get(setting.pref, setting.defaultPrefValue)) {
        enabledElement.classList.remove("hidden");
        disabledElement.classList.add("hidden");
      } else {
        enabledElement.classList.add("hidden");
        disabledElement.classList.remove("hidden");
      }
    }
  }
};

var PingPicker = {
  viewCurrentPingData: null,
  viewStructuredPingData: null,
  _archivedPings: null,

  attachObservers: function() {
    let elements = document.getElementsByName("choose-ping-source");
    for (let el of elements) {
      el.addEventListener("change", () => this.onPingSourceChanged(), false);
    }

    let displays = document.getElementsByName("choose-ping-display");
    for (let el of displays) {
      el.addEventListener("change", () => this.onPingDisplayChanged(), false);
    }

    document.getElementById("show-subsession-data").addEventListener("change", () => {
      this._updateCurrentPingData();
    });

    document.getElementById("choose-ping-week").addEventListener("change", () => {
      this._renderPingList();
      this._updateArchivedPingData();
    }, false);
    document.getElementById("choose-ping-id").addEventListener("change", () => {
      this._updateArchivedPingData()
    }, false);

    document.getElementById("newer-ping")
            .addEventListener("click", () => this._movePingIndex(-1), false);
    document.getElementById("older-ping")
            .addEventListener("click", () => this._movePingIndex(1), false);
    document.getElementById("choose-payload")
            .addEventListener("change", () => displayPingData(gPingData), false);
    document.getElementById("histograms-processes")
            .addEventListener("change", () => displayPingData(gPingData), false);
    document.getElementById("keyed-histograms-processes")
            .addEventListener("change", () => displayPingData(gPingData), false);
  },

  onPingSourceChanged: function() {
    this.update();
  },

  onPingDisplayChanged: function() {
    this.update();
  },

  update: Task.async(function*() {
    let viewCurrent = document.getElementById("ping-source-current").checked;
    let viewStructured = document.getElementById("ping-source-structured").checked;
    let currentChanged = viewCurrent !== this.viewCurrentPingData;
    let structuredChanged = viewStructured !== this.viewStructuredPingData;
    this.viewCurrentPingData = viewCurrent;
    this.viewStructuredPingData = viewStructured;

    // If we have no archived pings, disable the ping archive selection.
    // This can happen on new profiles or if the ping archive is disabled.
    let archivedPingList = yield TelemetryArchive.promiseArchivedPingList();
    let sourceArchived = document.getElementById("ping-source-archive");
    sourceArchived.disabled = (archivedPingList.length == 0);

    if (currentChanged) {
      if (this.viewCurrentPingData) {
        document.getElementById("current-ping-picker").classList.remove("hidden");
        document.getElementById("archived-ping-picker").classList.add("hidden");
        this._updateCurrentPingData();
      } else {
        document.getElementById("current-ping-picker").classList.add("hidden");
        yield this._updateArchivedPingList(archivedPingList);
        document.getElementById("archived-ping-picker").classList.remove("hidden");
      }
    }

    if (structuredChanged) {
      if (this.viewStructuredPingData) {
        this._showStructuredPingData();
      } else {
        this._showRawPingData();
      }
    }
  }),

  _updateCurrentPingData: function() {
    const subsession = document.getElementById("show-subsession-data").checked;
    const ping = TelemetryController.getCurrentPingData(subsession);
    if (!ping) {
      return;
    }
    displayPingData(ping, true);
  },

  _updateArchivedPingData: function() {
    let id = this._getSelectedPingId();
    return TelemetryArchive.promiseArchivedPingById(id)
                           .then((ping) => displayPingData(ping, true));
  },

  _updateArchivedPingList: Task.async(function*(pingList) {
    // The archived ping list is sorted in ascending timestamp order,
    // but descending is more practical for the operations we do here.
    pingList.reverse();

    this._archivedPings = pingList;

    // Collect the start dates for all the weeks we have pings for.
    let weekStart = (date) => {
      let weekDay = (date.getDay() + 6) % 7;
      let monday = new Date(date);
      monday.setDate(date.getDate() - weekDay);
      return TelemetryUtils.truncateToDays(monday);
    };

    let weekStartDates = new Set();
    for (let p of pingList) {
      weekStartDates.add(weekStart(new Date(p.timestampCreated)).getTime());
    }

    // Build a list of the week date ranges we have ping data for.
    let plusOneWeek = (date) => {
      let d = date;
      d.setDate(d.getDate() + 7);
      return d;
    };

    this._weeks = Array.from(weekStartDates.values(), startTime => ({
      startDate: new Date(startTime),
      endDate: plusOneWeek(new Date(startTime)),
    }));

    // Render the archive data.
    this._renderWeeks();
    this._renderPingList();

    // Update the displayed ping.
    yield this._updateArchivedPingData();
  }),

  _renderWeeks: function() {
    let weekSelector = document.getElementById("choose-ping-week");
    removeAllChildNodes(weekSelector);

    let index = 0;
    for (let week of this._weeks) {
      let text = shortDateString(week.startDate)
                 + " - " + shortDateString(yesterday(week.endDate));

      let option = document.createElement("option");
      let content = document.createTextNode(text);
      option.appendChild(content);
      weekSelector.appendChild(option);
    }
  },

  _getSelectedWeek: function() {
    let weekSelector = document.getElementById("choose-ping-week");
    return this._weeks[weekSelector.selectedIndex];
  },

  _renderPingList: function(id = null) {
    let pingSelector = document.getElementById("choose-ping-id");
    removeAllChildNodes(pingSelector);

    let weekRange = this._getSelectedWeek();
    let pings = this._archivedPings.filter(
      (p) => p.timestampCreated >= weekRange.startDate.getTime() &&
             p.timestampCreated < weekRange.endDate.getTime());

    for (let p of pings) {
      let date = new Date(p.timestampCreated);
      let text = shortDateString(date)
                 + " " + shortTimeString(date)
                 + " - " + p.type;

      let option = document.createElement("option");
      let content = document.createTextNode(text);
      option.appendChild(content);
      option.setAttribute("value", p.id);
      if (id && p.id == id) {
        option.selected = true;
      }
      pingSelector.appendChild(option);
    }
  },

  _getSelectedPingId: function() {
    let pingSelector = document.getElementById("choose-ping-id");
    let selected = pingSelector.selectedOptions.item(0);
    return selected.getAttribute("value");
  },

  _movePingIndex: function(offset) {
    const id = this._getSelectedPingId();
    const index = this._archivedPings.findIndex((p) => p.id == id);
    const newIndex = Math.min(Math.max(index + offset, 0), this._archivedPings.length - 1);
    const ping = this._archivedPings[newIndex];

    const weekIndex = this._weeks.findIndex(
      (week) => ping.timestampCreated >= week.startDate.getTime() &&
                ping.timestampCreated < week.endDate.getTime());
    const options = document.getElementById("choose-ping-week").options;
    options.item(weekIndex).selected = true;

    this._renderPingList(ping.id);
    this._updateArchivedPingData();
  },

  _showRawPingData: function() {
    document.getElementById("raw-ping-data-section").classList.remove("hidden");
    document.getElementById("structured-ping-data-section").classList.add("hidden");
  },

  _showStructuredPingData: function() {
    document.getElementById("raw-ping-data-section").classList.add("hidden");
    document.getElementById("structured-ping-data-section").classList.remove("hidden");
  },
};

var GeneralData = {
  /**
   * Renders the general data
   */
  render: function(aPing) {
    setHasData("general-data-section", true);
    let table = document.createElement("table");

    let caption = document.createElement("caption");
    let captionString = bundle.GetStringFromName("generalDataTitle");
    caption.appendChild(document.createTextNode(captionString + "\n"));
    table.appendChild(caption);

    let headings = document.createElement("tr");
    this.appendColumn(headings, "th", bundle.GetStringFromName("generalDataHeadingName") + "\t");
    this.appendColumn(headings, "th", bundle.GetStringFromName("generalDataHeadingValue") + "\t");
    table.appendChild(headings);

    // The payload & environment parts are handled by other renderers.
    let ignoreSections = ["payload", "environment"];
    let data = explodeObject(filterObject(aPing, ignoreSections));

    for (let [path, value] of data) {
        let row = document.createElement("tr");
        this.appendColumn(row, "td", path + "\t");
        this.appendColumn(row, "td", value + "\t");
        table.appendChild(row);
    }

    let dataDiv = document.getElementById("general-data");
    removeAllChildNodes(dataDiv);
    dataDiv.appendChild(table);
  },

  /**
   * Helper function for appending a column to the data table.
   *
   * @param aRowElement Parent row element
   * @param aColType Column's tag name
   * @param aColText Column contents
   */
  appendColumn: function(aRowElement, aColType, aColText) {
    let colElement = document.createElement(aColType);
    let colTextElement = document.createTextNode(aColText);
    colElement.appendChild(colTextElement);
    aRowElement.appendChild(colElement);
  },
};

var EnvironmentData = {
  /**
   * Renders the environment data
   */
  render: function(ping) {
    let dataDiv = document.getElementById("environment-data");
    removeAllChildNodes(dataDiv);
    const hasData = !!ping.environment;
    setHasData("environment-data-section", hasData);
    if (!hasData) {
      return;
    }

    let data = sectionalizeObject(ping.environment);

    for (let [section, sectionData] of data) {
      if (section == "addons") {
        break;
      }

      let table = document.createElement("table");
      this.appendHeading(table);

      for (let [path, value] of sectionData) {
        let row = document.createElement("tr");
        this.appendColumn(row, "td", path);
        this.appendColumn(row, "td", value);
        table.appendChild(row);
      }

      let hasData = sectionData.size > 0;
      this.createSubsection(section, hasData, table, dataDiv);
    }

    // We use specialized rendering here to make the addon and plugin listings
    // more readable.
    this.createAddonSection(dataDiv, ping);
  },

  createSubsection: function(title, hasSubdata, subSectionData, dataDiv) {
    let dataSection = document.createElement("section");
    dataSection.classList.add("data-subsection");

    if (hasSubdata) {
      dataSection.classList.add("has-subdata");
    }

    // Create section heading
    let sectionName = document.createElement("h2");
    sectionName.setAttribute("class", "section-name");
    sectionName.appendChild(document.createTextNode(title));
    sectionName.addEventListener("click", toggleSection, false);

    // Create caption for toggling the subsection visibility.
    let toggleCaption = document.createElement("span");
    toggleCaption.setAttribute("class", "toggle-caption");
    let toggleText = bundle.GetStringFromName("environmentDataSubsectionToggle");
    toggleCaption.appendChild(document.createTextNode(" " + toggleText));
    toggleCaption.addEventListener("click", toggleSection, false);

    // Create caption for empty subsections.
    let emptyCaption = document.createElement("span");
    emptyCaption.setAttribute("class", "empty-caption");
    let emptyText = bundle.GetStringFromName("environmentDataSubsectionEmpty");
    emptyCaption.appendChild(document.createTextNode(" " + emptyText));

    // Create data container
    let data = document.createElement("div");
    data.setAttribute("class", "subsection-data subdata");
    data.appendChild(subSectionData);

    // Append elements
    dataSection.appendChild(sectionName);
    dataSection.appendChild(toggleCaption);
    dataSection.appendChild(emptyCaption);
    dataSection.appendChild(data);

    dataDiv.appendChild(dataSection);
  },

  renderPersona: function(addonObj, addonSection, sectionTitle) {
    let table = document.createElement("table");
    table.setAttribute("id", sectionTitle);
    this.appendAddonSubsectionTitle(sectionTitle, table);
    this.appendRow(table, "persona", addonObj.persona);
    addonSection.appendChild(table);
  },

  renderActivePlugins: function(addonObj, addonSection, sectionTitle) {
    let data = explodeObject(addonObj);
    let table = document.createElement("table");
    table.setAttribute("id", sectionTitle);
    this.appendAddonSubsectionTitle(sectionTitle, table);

    for (let plugin of addonObj) {
      let data = explodeObject(plugin);
      this.appendHeadingName(table, data.get("name"));

      for (let [key, value] of data) {
        this.appendRow(table, key, value);
      }
    }

    addonSection.appendChild(table);
  },

  renderAddonsObject: function(addonObj, addonSection, sectionTitle) {
    let table = document.createElement("table");
    table.setAttribute("id", sectionTitle);
    this.appendAddonSubsectionTitle(sectionTitle, table);

    for (let id of Object.keys(addonObj)) {
      let addon = addonObj[id];
      this.appendHeadingName(table, addon.name || id);
      this.appendAddonID(table, id);
      let data = explodeObject(addon);

      for (let [key, value] of data) {
        this.appendRow(table, key, value);
      }
    }

    addonSection.appendChild(table);
  },

  renderKeyValueObject: function(addonObj, addonSection, sectionTitle) {
    let data = explodeObject(addonObj);
    let table = document.createElement("table");
    table.setAttribute("class", sectionTitle);
    this.appendAddonSubsectionTitle(sectionTitle, table);
    this.appendHeading(table);

    for (let [key, value] of data) {
      this.appendRow(table, key, value);
    }

    addonSection.appendChild(table);
  },

  appendAddonID: function(table, addonID) {
    this.appendRow(table, "id", addonID);
  },

  appendHeading: function(table) {
    let headings = document.createElement("tr");
    this.appendColumn(headings, "th", bundle.GetStringFromName("environmentDataHeadingName"));
    this.appendColumn(headings, "th", bundle.GetStringFromName("environmentDataHeadingValue"));
    table.appendChild(headings);
  },

  appendHeadingName: function(table, name) {
    let headings = document.createElement("tr");
    this.appendColumn(headings, "th", name);
    headings.cells[0].colSpan = 2;
    table.appendChild(headings);
  },

  appendAddonSubsectionTitle: function(section, table) {
    let caption = document.createElement("caption");
    caption.setAttribute("class", "addon-caption");
    caption.appendChild(document.createTextNode(section));
    table.appendChild(caption);
  },

  createAddonSection: function(dataDiv, ping) {
    let addonSection = document.createElement("div");
    let addons = ping.environment.addons;
    this.renderAddonsObject(addons.activeAddons, addonSection, "activeAddons");
    this.renderActivePlugins(addons.activePlugins, addonSection, "activePlugins");
    this.renderKeyValueObject(addons.theme, addonSection, "theme");
    this.renderKeyValueObject(addons.activeExperiment, addonSection, "activeExperiment");
    this.renderAddonsObject(addons.activeGMPlugins, addonSection, "activeGMPlugins");
    this.renderPersona(addons, addonSection, "persona");

    let hasAddonData = Object.keys(ping.environment.addons).length > 0;
    this.createSubsection("addons", hasAddonData, addonSection, dataDiv);
  },

  appendRow: function(table, id, value) {
    let row = document.createElement("tr");
    this.appendColumn(row, "td", id);
    this.appendColumn(row, "td", value);
    table.appendChild(row);
  },
  /**
   * Helper function for appending a column to the data table.
   *
   * @param aRowElement Parent row element
   * @param aColType Column's tag name
   * @param aColText Column contents
   */
  appendColumn: function(aRowElement, aColType, aColText) {
    let colElement = document.createElement(aColType);
    let colTextElement = document.createTextNode(aColText);
    colElement.appendChild(colTextElement);
    aRowElement.appendChild(colElement);
  },
};

var TelLog = {
  /**
   * Renders the telemetry log
   */
  render: function(aPing) {
    let entries = aPing.payload.log;
    const hasData = entries && entries.length > 0;
    setHasData("telemetry-log-section", hasData);
    if (!hasData) {
      return;
    }

    let table = document.createElement("table");

    let caption = document.createElement("caption");
    let captionString = bundle.GetStringFromName("telemetryLogTitle");
    caption.appendChild(document.createTextNode(captionString + "\n"));
    table.appendChild(caption);

    let headings = document.createElement("tr");
    this.appendColumn(headings, "th", bundle.GetStringFromName("telemetryLogHeadingId") + "\t");
    this.appendColumn(headings, "th", bundle.GetStringFromName("telemetryLogHeadingTimestamp") + "\t");
    this.appendColumn(headings, "th", bundle.GetStringFromName("telemetryLogHeadingData") + "\t");
    table.appendChild(headings);

    for (let entry of entries) {
        let row = document.createElement("tr");
        for (let elem of entry) {
            this.appendColumn(row, "td", elem + "\t");
        }
        table.appendChild(row);
    }

    let dataDiv = document.getElementById("telemetry-log");
    removeAllChildNodes(dataDiv);
    dataDiv.appendChild(table);
  },

  /**
   * Helper function for appending a column to the data table.
   *
   * @param aRowElement Parent row element
   * @param aColType Column's tag name
   * @param aColText Column contents
   */
  appendColumn: function(aRowElement, aColType, aColText) {
    let colElement = document.createElement(aColType);
    let colTextElement = document.createTextNode(aColText);
    colElement.appendChild(colTextElement);
    aRowElement.appendChild(colElement);
  },
};

var SlowSQL = {

  slowSqlHits: bundle.GetStringFromName("slowSqlHits"),

  slowSqlAverage: bundle.GetStringFromName("slowSqlAverage"),

  slowSqlStatement: bundle.GetStringFromName("slowSqlStatement"),

  mainThreadTitle: bundle.GetStringFromName("slowSqlMain"),

  otherThreadTitle: bundle.GetStringFromName("slowSqlOther"),

  /**
   * Render slow SQL statistics
   */
  render: function SlowSQL_render(aPing) {
    // We can add the debug SQL data to the current ping later.
    // However, we need to be careful to never send that debug data
    // out due to privacy concerns.
    // We want to show the actual ping data for archived pings,
    // so skip this there.
    let debugSlowSql = PingPicker.viewCurrentPingData && Preferences.get(PREF_DEBUG_SLOW_SQL, false);
    let slowSql = debugSlowSql ? Telemetry.debugSlowSQL : aPing.payload.slowSQL;
    if (!slowSql) {
      setHasData("slow-sql-section", false);
      return;
    }

    let {mainThread, otherThreads} =
      debugSlowSql ? Telemetry.debugSlowSQL : aPing.payload.slowSQL;

    let mainThreadCount = Object.keys(mainThread).length;
    let otherThreadCount = Object.keys(otherThreads).length;
    if (mainThreadCount == 0 && otherThreadCount == 0) {
      setHasData("slow-sql-section", false);
      return;
    }

    setHasData("slow-sql-section", true);
    if (debugSlowSql) {
      document.getElementById("sql-warning").classList.remove("hidden");
    }

    let slowSqlDiv = document.getElementById("slow-sql-tables");
    removeAllChildNodes(slowSqlDiv);

    // Main thread
    if (mainThreadCount > 0) {
      let table = document.createElement("table");
      this.renderTableHeader(table, this.mainThreadTitle);
      this.renderTable(table, mainThread);

      slowSqlDiv.appendChild(table);
      slowSqlDiv.appendChild(document.createElement("hr"));
    }

    // Other threads
    if (otherThreadCount > 0) {
      let table = document.createElement("table");
      this.renderTableHeader(table, this.otherThreadTitle);
      this.renderTable(table, otherThreads);

      slowSqlDiv.appendChild(table);
      slowSqlDiv.appendChild(document.createElement("hr"));
    }
  },

  /**
   * Creates a header row for a Slow SQL table
   * Tabs & newlines added to cells to make it easier to copy-paste.
   *
   * @param aTable Parent table element
   * @param aTitle Table's title
   */
  renderTableHeader: function SlowSQL_renderTableHeader(aTable, aTitle) {
    let caption = document.createElement("caption");
    caption.appendChild(document.createTextNode(aTitle + "\n"));
    aTable.appendChild(caption);

    let headings = document.createElement("tr");
    this.appendColumn(headings, "th", this.slowSqlHits + "\t");
    this.appendColumn(headings, "th", this.slowSqlAverage + "\t");
    this.appendColumn(headings, "th", this.slowSqlStatement + "\n");
    aTable.appendChild(headings);
  },

  /**
   * Fills out the table body
   * Tabs & newlines added to cells to make it easier to copy-paste.
   *
   * @param aTable Parent table element
   * @param aSql SQL stats object
   */
  renderTable: function SlowSQL_renderTable(aTable, aSql) {
    for (let [sql, [hitCount, totalTime]] of Object.entries(aSql)) {
      let averageTime = totalTime / hitCount;

      let sqlRow = document.createElement("tr");

      this.appendColumn(sqlRow, "td", hitCount + "\t");
      this.appendColumn(sqlRow, "td", averageTime.toFixed(0) + "\t");
      this.appendColumn(sqlRow, "td", sql + "\n");

      aTable.appendChild(sqlRow);
    }
  },

  /**
   * Helper function for appending a column to a Slow SQL table.
   *
   * @param aRowElement Parent row element
   * @param aColType Column's tag name
   * @param aColText Column contents
   */
  appendColumn: function SlowSQL_appendColumn(aRowElement, aColType, aColText) {
    let colElement = document.createElement(aColType);
    let colTextElement = document.createTextNode(aColText);
    colElement.appendChild(colTextElement);
    aRowElement.appendChild(colElement);
  }
};

var StackRenderer = {

  stackTitle: bundle.GetStringFromName("stackTitle"),

  memoryMapTitle: bundle.GetStringFromName("memoryMapTitle"),

  /**
   * Outputs the memory map associated with this hang report
   *
   * @param aDiv Output div
   */
  renderMemoryMap: function StackRenderer_renderMemoryMap(aDiv, memoryMap) {
    aDiv.appendChild(document.createTextNode(this.memoryMapTitle));
    aDiv.appendChild(document.createElement("br"));

    for (let currentModule of memoryMap) {
      aDiv.appendChild(document.createTextNode(currentModule.join(" ")));
      aDiv.appendChild(document.createElement("br"));
    }

    aDiv.appendChild(document.createElement("br"));
  },

  /**
   * Outputs the raw PCs from the hang's stack
   *
   * @param aDiv Output div
   * @param aStack Array of PCs from the hang stack
   */
  renderStack: function StackRenderer_renderStack(aDiv, aStack) {
    aDiv.appendChild(document.createTextNode(this.stackTitle));
    let stackText = " " + aStack.join(" ");
    aDiv.appendChild(document.createTextNode(stackText));

    aDiv.appendChild(document.createElement("br"));
    aDiv.appendChild(document.createElement("br"));
  },
  renderStacks: function StackRenderer_renderStacks(aPrefix, aStacks,
                                                    aMemoryMap, aRenderHeader) {
    let div = document.getElementById(aPrefix + '-data');
    removeAllChildNodes(div);

    let fetchE = document.getElementById(aPrefix + '-fetch-symbols');
    if (fetchE) {
      fetchE.classList.remove("hidden");
    }
    let hideE = document.getElementById(aPrefix + '-hide-symbols');
    if (hideE) {
      hideE.classList.add("hidden");
    }

    if (aStacks.length == 0) {
      return;
    }

    setHasData(aPrefix + '-section', true);

    this.renderMemoryMap(div, aMemoryMap);

    for (let i = 0; i < aStacks.length; ++i) {
      let stack = aStacks[i];
      aRenderHeader(i);
      this.renderStack(div, stack)
    }
  },

  /**
   * Renders the title of the stack: e.g. "Late Write #1" or
   * "Hang Report #1 (6 seconds)".
   *
   * @param aFormatArgs formating args to be passed to formatStringFromName.
   */
  renderHeader: function StackRenderer_renderHeader(aPrefix, aFormatArgs) {
    let div = document.getElementById(aPrefix + "-data");

    let titleElement = document.createElement("span");
    titleElement.className = "stack-title";

    let titleText = bundle.formatStringFromName(
      aPrefix + "-title", aFormatArgs, aFormatArgs.length);
    titleElement.appendChild(document.createTextNode(titleText));

    div.appendChild(titleElement);
    div.appendChild(document.createElement("br"));
  }
};

var RawPayload = {
  /**
   * Renders the raw payload
   */
  render: function(aPing) {
    setHasData("raw-payload-section", true);
    let pre = document.getElementById("raw-payload-data-pre");
    pre.textContent = JSON.stringify(aPing.payload, null, 2);
  }
};

function SymbolicationRequest(aPrefix, aRenderHeader,
                              aMemoryMap, aStacks, aDurations = null) {
  this.prefix = aPrefix;
  this.renderHeader = aRenderHeader;
  this.memoryMap = aMemoryMap;
  this.stacks = aStacks;
  this.durations = aDurations;
}
/**
 * A callback for onreadystatechange. It replaces the numeric stack with
 * the symbolicated one returned by the symbolication server.
 */
SymbolicationRequest.prototype.handleSymbolResponse =
function SymbolicationRequest_handleSymbolResponse() {
  if (this.symbolRequest.readyState != 4)
    return;

  let fetchElement = document.getElementById(this.prefix + "-fetch-symbols");
  fetchElement.classList.add("hidden");
  let hideElement = document.getElementById(this.prefix + "-hide-symbols");
  hideElement.classList.remove("hidden");
  let div = document.getElementById(this.prefix + "-data");
  removeAllChildNodes(div);
  let errorMessage = bundle.GetStringFromName("errorFetchingSymbols");

  if (this.symbolRequest.status != 200) {
    div.appendChild(document.createTextNode(errorMessage));
    return;
  }

  let jsonResponse = {};
  try {
    jsonResponse = JSON.parse(this.symbolRequest.responseText);
  } catch (e) {
    div.appendChild(document.createTextNode(errorMessage));
    return;
  }

  for (let i = 0; i < jsonResponse.length; ++i) {
    let stack = jsonResponse[i];
    this.renderHeader(i, this.durations);

    for (let symbol of stack) {
      div.appendChild(document.createTextNode(symbol));
      div.appendChild(document.createElement("br"));
    }
    div.appendChild(document.createElement("br"));
  }
};
/**
 * Send a request to the symbolication server to symbolicate this stack.
 */
SymbolicationRequest.prototype.fetchSymbols =
function SymbolicationRequest_fetchSymbols() {
  let symbolServerURI =
    Preferences.get(PREF_SYMBOL_SERVER_URI, DEFAULT_SYMBOL_SERVER_URI);
  let request = {"memoryMap" : this.memoryMap, "stacks" : this.stacks,
                 "version" : 3};
  let requestJSON = JSON.stringify(request);

  this.symbolRequest = new XMLHttpRequest();
  this.symbolRequest.open("POST", symbolServerURI, true);
  this.symbolRequest.setRequestHeader("Content-type", "application/json");
  this.symbolRequest.setRequestHeader("Content-length",
                                      requestJSON.length);
  this.symbolRequest.setRequestHeader("Connection", "close");
  this.symbolRequest.onreadystatechange = this.handleSymbolResponse.bind(this);
  this.symbolRequest.send(requestJSON);
}

var ChromeHangs = {

  symbolRequest: null,

  /**
   * Renders raw chrome hang data
   */
  render: function ChromeHangs_render(aPing) {
    let hangs = aPing.payload.chromeHangs;
    setHasData("chrome-hangs-section", !!hangs);
    if (!hangs) {
      return;
    }

    let stacks = hangs.stacks;
    let memoryMap = hangs.memoryMap;
    let durations = hangs.durations;

    StackRenderer.renderStacks("chrome-hangs", stacks, memoryMap,
                               (index) => this.renderHangHeader(index, durations));
  },

  renderHangHeader: function ChromeHangs_renderHangHeader(aIndex, aDurations) {
    StackRenderer.renderHeader("chrome-hangs", [aIndex + 1, aDurations[aIndex]]);
  }
};

var ThreadHangStats = {

  /**
   * Renders raw thread hang stats data
   */
  render: function(aPayload) {
    let div = document.getElementById("thread-hang-stats");
    removeAllChildNodes(div);

    let stats = aPayload.threadHangStats;
    setHasData("thread-hang-stats-section", stats && (stats.length > 0));
    if (!stats) {
      return;
    }

    stats.forEach((thread) => {
      div.appendChild(this.renderThread(thread));
    });
  },

  /**
   * Creates and fills data corresponding to a thread
   */
  renderThread: function(aThread) {
    let div = document.createElement("div");

    let title = document.createElement("h2");
    title.textContent = aThread.name;
    div.appendChild(title);

    // Don't localize the histogram name, because the
    // name is also used as the div element's ID
    Histogram.render(div, aThread.name + "-Activity",
                     aThread.activity, {exponential: true}, true);
    aThread.hangs.forEach((hang, index) => {
      let hangName = aThread.name + "-Hang-" + (index + 1);
      let hangDiv = Histogram.render(
        div, hangName, hang.histogram, {exponential: true}, true);
      let stackDiv = document.createElement("div");
      let stack = hang.nativeStack || hang.stack;
      stack.forEach((frame) => {
        stackDiv.appendChild(document.createTextNode(frame));
        // Leave an extra <br> at the end of the stack listing
        stackDiv.appendChild(document.createElement("br"));
      });
      // Insert stack after the histogram title
      hangDiv.insertBefore(stackDiv, hangDiv.childNodes[1]);
    });
    return div;
  },
};

var Histogram = {

  hgramSamplesCaption: bundle.GetStringFromName("histogramSamples"),

  hgramAverageCaption: bundle.GetStringFromName("histogramAverage"),

  hgramSumCaption: bundle.GetStringFromName("histogramSum"),

  hgramCopyCaption: bundle.GetStringFromName("histogramCopy"),

  /**
   * Renders a single Telemetry histogram
   *
   * @param aParent Parent element
   * @param aName Histogram name
   * @param aHgram Histogram information
   * @param aOptions Object with render options
   *                 * exponential: bars follow logarithmic scale
   * @param aIsBHR whether or not requires fixing the labels for TimeHistogram
   */
  render: function Histogram_render(aParent, aName, aHgram, aOptions, aIsBHR) {
    let options = aOptions || {};
    let hgram = this.processHistogram(aHgram, aName, aIsBHR);

    let outerDiv = document.createElement("div");
    outerDiv.className = "histogram";
    outerDiv.id = aName;

    let divTitle = document.createElement("div");
    divTitle.className = "histogram-title";
    divTitle.appendChild(document.createTextNode(aName));
    outerDiv.appendChild(divTitle);

    let stats = hgram.sample_count + " " + this.hgramSamplesCaption + ", " +
                this.hgramAverageCaption + " = " + hgram.pretty_average + ", " +
                this.hgramSumCaption + " = " + hgram.sum;

    let divStats = document.createElement("div");
    divStats.appendChild(document.createTextNode(stats));
    outerDiv.appendChild(divStats);

    if (isRTL()) {
      hgram.buckets.reverse();
      hgram.values.reverse();
    }

    let textData = this.renderValues(outerDiv, hgram, options);

    // The 'Copy' button contains the textual data, copied to clipboard on click
    let copyButton = document.createElement("button");
    copyButton.className = "copy-node";
    copyButton.appendChild(document.createTextNode(this.hgramCopyCaption));
    copyButton.histogramText = aName + EOL + stats + EOL + EOL + textData;
    copyButton.addEventListener("click", function() {
      Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper)
                                                 .copyString(this.histogramText);
    });
    outerDiv.appendChild(copyButton);

    aParent.appendChild(outerDiv);
    return outerDiv;
  },

  processHistogram: function(aHgram, aName, aIsBHR) {
    const values = Object.keys(aHgram.values).map(k => aHgram.values[k]);
    if (!values.length) {
      // If we have no values collected for this histogram, just return
      // zero values so we still render it.
      return {
        values: [],
        pretty_average: 0,
        max: 0,
        sample_count: 0,
        sum: 0
      };
    }

    const sample_count = values.reduceRight((a, b) => a + b);
    const average = Math.round(aHgram.sum * 10 / sample_count) / 10;
    const max_value = Math.max(...values);

    function labelFunc(k) {
      // - BHR histograms are TimeHistograms: Exactly power-of-two buckets (from 0)
      //   (buckets: [0..1], [2..3], [4..7], [8..15], ... note the 0..1 anomaly - same bucket)
      // - TimeHistogram's JS representation adds a dummy (empty) "0" bucket, and
      //   the rest of the buckets have the label as the upper value of the
      //   bucket (non TimeHistograms have the lower value of the bucket as label).
      //   So JS TimeHistograms bucket labels are: 0 (dummy), 1, 3, 7, 15, ...
      // - see toolkit/components/telemetry/Telemetry.cpp
      //   (CreateJSTimeHistogram, CreateJSThreadHangStats, CreateJSHangHistogram)
      // - see toolkit/components/telemetry/ThreadHangStats.h
      // Fix BHR labels to the "standard" format for about:telemetry as follows:
      //   - The dummy 0 label+bucket will be filtered before arriving here
      //   - If it's 1 -> manually correct it to 0 (the 0..1 anomaly)
      //   - For the rest, set the label as the bottom value instead of the upper.
      //   --> so we'll end with the following (non dummy) labels: 0, 2, 4, 8, 16, ...
      if (!aIsBHR) {
        return k;
      }
      return k == 1 ? 0 : (k + 1) / 2;
    }

    const labelledValues = Object.keys(aHgram.values)
                           .filter(label => !aIsBHR || Number(label) != 0) // remove dummy 0 label for BHR
                           .map(k => [labelFunc(Number(k)), aHgram.values[k]]);

    let result = {
      values: labelledValues,
      pretty_average: average,
      max: max_value,
      sample_count: sample_count,
      sum: aHgram.sum
    };

    return result;
  },

  /**
   * Return a non-negative, logarithmic representation of a non-negative number.
   * e.g. 0 => 0, 1 => 1, 10 => 2, 100 => 3
   *
   * @param aNumber Non-negative number
   */
  getLogValue: function(aNumber) {
    return Math.max(0, Math.log10(aNumber) + 1);
  },

  /**
   * Create histogram HTML bars, also returns a textual representation
   * Both aMaxValue and aSumValues must be positive.
   * Values are assumed to use 0 as baseline.
   *
   * @param aDiv Outer parent div
   * @param aHgram The histogram data
   * @param aOptions Object with render options (@see #render)
   */
  renderValues: function Histogram_renderValues(aDiv, aHgram, aOptions) {
    let text = "";
    // If the last label is not the longest string, alignment will break a little
    let labelPadTo = 0;
    if (aHgram.values.length) {
      labelPadTo = String(aHgram.values[aHgram.values.length - 1][0]).length;
    }
    let maxBarValue = aOptions.exponential ? this.getLogValue(aHgram.max) : aHgram.max;

    for (let [label, value] of aHgram.values) {
      let barValue = aOptions.exponential ? this.getLogValue(value) : value;

      // Create a text representation: <right-aligned-label> |<bar-of-#><value>  <percentage>
      text += EOL
              + " ".repeat(Math.max(0, labelPadTo - String(label).length)) + label // Right-aligned label
              + " |" + "#".repeat(Math.round(MAX_BAR_CHARS * barValue / maxBarValue)) // Bar
              + "  " + value // Value
              + "  " + Math.round(100 * value / aHgram.sample_count) + "%"; // Percentage

      // Construct the HTML labels + bars
      let belowEm = Math.round(MAX_BAR_HEIGHT * (barValue / maxBarValue) * 10) / 10;
      let aboveEm = MAX_BAR_HEIGHT - belowEm;

      let barDiv = document.createElement("div");
      barDiv.className = "bar";
      barDiv.style.paddingTop = aboveEm + "em";

      // Add value label or an nbsp if no value
      barDiv.appendChild(document.createTextNode(value ? value : '\u00A0'));

      // Create the blue bar
      let bar = document.createElement("div");
      bar.className = "bar-inner";
      bar.style.height = belowEm + "em";
      barDiv.appendChild(bar);

      // Add bucket label
      barDiv.appendChild(document.createTextNode(label));

      aDiv.appendChild(barDiv);
    }

    return text.substr(EOL.length); // Trim the EOL before the first line
  },

  /**
   * Helper function for filtering histogram elements by their id
   * Adds the "filter-blocked" class to histogram nodes whose IDs don't match the filter.
   *
   * @param aContainerNode Container node containing the histogram class nodes to filter
   * @param aFilterText either text or /RegEx/. If text, case-insensitive and AND words
   */
  filterHistograms: function _filterHistograms(aContainerNode, aFilterText) {
    let filter = aFilterText.toString();

    // Pass if: all non-empty array items match (case-sensitive)
    function isPassText(subject, filter) {
      for (let item of filter) {
        if (item.length && subject.indexOf(item) < 0) {
          return false; // mismatch and not a spurious space
        }
      }
      return true;
    }

    function isPassRegex(subject, filter) {
      return filter.test(subject);
    }

    // Setup normalized filter string (trimmed, lower cased and split on spaces if not RegEx)
    let isPassFunc; // filter function, set once, then applied to all elements
    filter = filter.trim();
    if (filter[0] != "/") { // Plain text: case insensitive, AND if multi-string
      isPassFunc = isPassText;
      filter = filter.toLowerCase().split(" ");
    } else {
      isPassFunc = isPassRegex;
      var r = filter.match(/^\/(.*)\/(i?)$/);
      try {
        filter = RegExp(r[1], r[2]);
      }
      catch (e) { // Incomplete or bad RegExp - always no match
        isPassFunc = function() {
          return false;
        };
      }
    }

    let needLower = (isPassFunc === isPassText);

    let histograms = aContainerNode.getElementsByClassName("histogram");
    for (let hist of histograms) {
      hist.classList[isPassFunc((needLower ? hist.id.toLowerCase() : hist.id), filter) ? "remove" : "add"]("filter-blocked");
    }
  },

  /**
   * Event handler for change at histograms filter input
   *
   * When invoked, 'this' is expected to be the filter HTML node.
   */
  histogramFilterChanged: function _histogramFilterChanged() {
    if (this.idleTimeout) {
      clearTimeout(this.idleTimeout);
    }

    this.idleTimeout = setTimeout( () => {
      Histogram.filterHistograms(document.getElementById(this.getAttribute("target_id")), this.value);
    }, FILTER_IDLE_TIMEOUT);
  }
};

/*
 * Helper function to render JS objects with white space between top level elements
 * so that they look better in the browser
 * @param   aObject JavaScript object or array to render
 * @return  String
 */
function RenderObject(aObject) {
  let output = "";
  if (Array.isArray(aObject)) {
    if (aObject.length == 0) {
      return "[]";
    }
    output = "[" + JSON.stringify(aObject[0]);
    for (let i = 1; i < aObject.length; i++) {
      output += ", " + JSON.stringify(aObject[i]);
    }
    return output + "]";
  }
  let keys = Object.keys(aObject);
  if (keys.length == 0) {
    return "{}";
  }
  output = "{\"" + keys[0] + "\":\u00A0" + JSON.stringify(aObject[keys[0]]);
  for (let i = 1; i < keys.length; i++) {
    output += ", \"" + keys[i] + "\":\u00A0" + JSON.stringify(aObject[keys[i]]);
  }
  return output + "}";
}

var KeyValueTable = {
  /**
   * Returns a 2-column table with keys and values
   * @param aMeasurements Each key in this JS object is rendered as a row in
   *                      the table with its corresponding value
   * @param aKeysLabel    Column header for the keys column
   * @param aValuesLabel  Column header for the values column
   */
  render: function KeyValueTable_render(aMeasurements, aKeysLabel, aValuesLabel) {
    let table = document.createElement("table");
    this.renderHeader(table, aKeysLabel, aValuesLabel);
    this.renderBody(table, aMeasurements);
    return table;
  },

  /**
   * Create the table header
   * Tabs & newlines added to cells to make it easier to copy-paste.
   *
   * @param aTable Table element
   * @param aKeysLabel    Column header for the keys column
   * @param aValuesLabel  Column header for the values column
   */
  renderHeader: function KeyValueTable_renderHeader(aTable, aKeysLabel, aValuesLabel) {
    let headerRow = document.createElement("tr");
    aTable.appendChild(headerRow);

    let keysColumn = document.createElement("th");
    keysColumn.appendChild(document.createTextNode(aKeysLabel + "\t"));
    let valuesColumn = document.createElement("th");
    valuesColumn.appendChild(document.createTextNode(aValuesLabel + "\n"));

    headerRow.appendChild(keysColumn);
    headerRow.appendChild(valuesColumn);
  },

  /**
   * Create the table body
   * Tabs & newlines added to cells to make it easier to copy-paste.
   *
   * @param aTable Table element
   * @param aMeasurements Key/value map
   */
  renderBody: function KeyValueTable_renderBody(aTable, aMeasurements) {
    for (let [key, value] of Object.entries(aMeasurements)) {
      // use .valueOf() to unbox Number, String, etc. objects
      if (value &&
         (typeof value == "object") &&
         (typeof value.valueOf() == "object")) {
        value = RenderObject(value);
      }

      let newRow = document.createElement("tr");
      aTable.appendChild(newRow);

      let keyField = document.createElement("td");
      keyField.appendChild(document.createTextNode(key + "\t"));
      newRow.appendChild(keyField);

      let valueField = document.createElement("td");
      valueField.appendChild(document.createTextNode(value + "\n"));
      newRow.appendChild(valueField);
    }
  }
};

var GenericTable = {
  /**
   * Returns a n-column table.
   * @param rows An array of arrays, each containing data to render
   *             for one row.
   * @param headings The column header strings.
   */
  render: function(rows, headings) {
    let table = document.createElement("table");
    this.renderHeader(table, headings);
    this.renderBody(table, rows);
    return table;
  },

  /**
   * Create the table header.
   * Tabs & newlines added to cells to make it easier to copy-paste.
   *
   * @param table Table element
   * @param headings Array of column header strings.
   */
  renderHeader: function(table, headings) {
    let headerRow = document.createElement("tr");
    table.appendChild(headerRow);

    for (let i = 0; i < headings.length; ++i) {
      let suffix = (i == (headings.length - 1)) ? "\n" : "\t";
      let column = document.createElement("th");
      column.appendChild(document.createTextNode(headings[i] + suffix));
      headerRow.appendChild(column);
    }
  },

  /**
   * Create the table body
   * Tabs & newlines added to cells to make it easier to copy-paste.
   *
   * @param table Table element
   * @param rows An array of arrays, each containing data to render
   *             for one row.
   */
  renderBody: function(table, rows) {
    for (let row of rows) {
      row = row.map(value => {
        // use .valueOf() to unbox Number, String, etc. objects
        if (value &&
           (typeof value == "object") &&
           (typeof value.valueOf() == "object")) {
          return RenderObject(value);
        }
        return value;
      });

      let newRow = document.createElement("tr");
      table.appendChild(newRow);

      for (let i = 0; i < row.length; ++i) {
        let suffix = (i == (row.length - 1)) ? "\n" : "\t";
        let field = document.createElement("td");
        field.appendChild(document.createTextNode(row[i] + suffix));
        newRow.appendChild(field);
      }
    }
  }
};

var KeyedHistogram = {
  render: function(parent, id, keyedHistogram) {
    let outerDiv = document.createElement("div");
    outerDiv.className = "keyed-histogram";
    outerDiv.id = id;

    let divTitle = document.createElement("div");
    divTitle.className = "keyed-histogram-title";
    divTitle.appendChild(document.createTextNode(id));
    outerDiv.appendChild(divTitle);

    for (let [name, hgram] of Object.entries(keyedHistogram)) {
      Histogram.render(outerDiv, name, hgram);
    }

    parent.appendChild(outerDiv);
    return outerDiv;
  },
};

var AddonDetails = {
  tableIDTitle: bundle.GetStringFromName("addonTableID"),
  tableDetailsTitle: bundle.GetStringFromName("addonTableDetails"),

  /**
   * Render the addon details section as a series of headers followed by key/value tables
   * @param aPing A ping object to render the data from.
   */
  render: function AddonDetails_render(aPing) {
    let addonSection = document.getElementById("addon-details");
    removeAllChildNodes(addonSection);
    let addonDetails = aPing.payload.addonDetails;
    const hasData = addonDetails && Object.keys(addonDetails).length > 0;
    setHasData("addon-details-section", hasData);
    if (!hasData) {
      return;
    }

    for (let provider in addonDetails) {
      let providerSection = document.createElement("h2");
      let titleText = bundle.formatStringFromName("addonProvider", [provider], 1);
      providerSection.appendChild(document.createTextNode(titleText));
      addonSection.appendChild(providerSection);
      addonSection.appendChild(
        KeyValueTable.render(addonDetails[provider],
                             this.tableIDTitle, this.tableDetailsTitle));
    }
  }
};

var Scalars = {
  /**
   * Render the scalar data - if present - from the payload in a simple key-value table.
   * @param aPayload A payload object to render the data from.
   */
  render: function(aPayload) {
    let scalarsSection = document.getElementById("scalars");
    removeAllChildNodes(scalarsSection);

    if (!aPayload.processes || !aPayload.processes.parent) {
      return;
    }

    let scalars = aPayload.processes.parent.scalars;
    const hasData = scalars && Object.keys(scalars).length > 0;
    setHasData("scalars-section", hasData);
    if (!hasData) {
      return;
    }

    const headingName = bundle.GetStringFromName("namesHeader");
    const headingValue = bundle.GetStringFromName("valuesHeader");
    const table = KeyValueTable.render(scalars, headingName, headingValue);
    scalarsSection.appendChild(table);
  }
};

var KeyedScalars = {
  /**
   * Render the keyed scalar data - if present - from the payload in a simple key-value table.
   * @param aPayload A payload object to render the data from.
   */
  render: function(aPayload) {
    let scalarsSection = document.getElementById("keyed-scalars");
    removeAllChildNodes(scalarsSection);

    if (!aPayload.processes || !aPayload.processes.parent) {
      return;
    }

    let keyedScalars = aPayload.processes.parent.keyedScalars;
    const hasData = keyedScalars && Object.keys(keyedScalars).length > 0;
    setHasData("keyed-scalars-section", hasData);
    if (!hasData) {
      return;
    }

    const headingName = bundle.GetStringFromName("namesHeader");
    const headingValue = bundle.GetStringFromName("valuesHeader");
    for (let scalar in keyedScalars) {
      // Add the name of the scalar.
      let scalarNameSection = document.createElement("h2");
      scalarNameSection.appendChild(document.createTextNode(scalar));
      scalarsSection.appendChild(scalarNameSection);
      // Populate the section with the key-value pairs from the scalar.
      const table = KeyValueTable.render(keyedScalars[scalar], headingName, headingValue);
      scalarsSection.appendChild(table);
    }
  }
};

var Events = {
  /**
   * Render the event data - if present - from the payload in a simple table.
   * @param aPayload A payload object to render the data from.
   */
  render: function(aPayload) {
    let eventsSection = document.getElementById("events");
    removeAllChildNodes(eventsSection);

    if (!aPayload.processes || !aPayload.processes.parent) {
      return;
    }

    const events = aPayload.processes.parent.events;
    const hasData = events && Object.keys(events).length > 0;
    setHasData("events-section", hasData);
    if (!hasData) {
      return;
    }

    const headings = [
      "timestamp",
      "category",
      "method",
      "object",
      "value",
      "extra",
    ];

    const table = GenericTable.render(events, headings);
    eventsSection.appendChild(table);
  }
};

/**
 * Helper function for showing either the toggle element or "No data collected" message for a section
 *
 * @param aSectionID ID of the section element that needs to be changed
 * @param aHasData true (default) indicates that toggle should be displayed
 */
function setHasData(aSectionID, aHasData) {
  let sectionElement = document.getElementById(aSectionID);
  sectionElement.classList[aHasData ? "add" : "remove"]("has-data");
}

/**
 * Helper function that expands and collapses sections +
 * changes caption on the toggle text
 */
function toggleSection(aEvent) {
  let parentElement = aEvent.target.parentElement;
  if (!parentElement.classList.contains("has-data") &&
      !parentElement.classList.contains("has-subdata")) {
    return; // nothing to toggle
  }

  parentElement.classList.toggle("expanded");

  // Store section opened/closed state in a hidden checkbox (which is then used on reload)
  let statebox = parentElement.getElementsByClassName("statebox")[0];
  if (statebox) {
    statebox.checked = parentElement.classList.contains("expanded");
  }
}

/**
 * Sets the text of the page header based on a config pref + bundle strings
 */
function setupPageHeader()
{
  let serverOwner = Preferences.get(PREF_TELEMETRY_SERVER_OWNER, "Mozilla");
  let brandName = brandBundle.GetStringFromName("brandFullName");
  let subtitleText = bundle.formatStringFromName(
    "pageSubtitle", [serverOwner, brandName], 2);

  let subtitleElement = document.getElementById("page-subtitle");
  subtitleElement.appendChild(document.createTextNode(subtitleText));
}

/**
 * Initializes load/unload, pref change and mouse-click listeners
 */
function setupListeners() {
  Settings.attachObservers();
  PingPicker.attachObservers();

  // Clean up observers when page is closed
  window.addEventListener("unload",
    function unloadHandler(aEvent) {
      window.removeEventListener("unload", unloadHandler);
      Settings.detachObservers();
  }, false);

  document.getElementById("chrome-hangs-fetch-symbols").addEventListener("click",
    function () {
      if (!gPingData) {
        return;
      }

      let hangs = gPingData.payload.chromeHangs;
      let req = new SymbolicationRequest("chrome-hangs",
                                         ChromeHangs.renderHangHeader,
                                         hangs.memoryMap,
                                         hangs.stacks,
                                         hangs.durations);
      req.fetchSymbols();
  }, false);

  document.getElementById("chrome-hangs-hide-symbols").addEventListener("click",
    function () {
      if (!gPingData) {
        return;
      }

      ChromeHangs.render(gPingData);
  }, false);

  document.getElementById("late-writes-fetch-symbols").addEventListener("click",
    function () {
      if (!gPingData) {
        return;
      }

      let lateWrites = gPingData.payload.lateWrites;
      let req = new SymbolicationRequest("late-writes",
                                         LateWritesSingleton.renderHeader,
                                         lateWrites.memoryMap,
                                         lateWrites.stacks);
      req.fetchSymbols();
  }, false);

  document.getElementById("late-writes-hide-symbols").addEventListener("click",
    function () {
      if (!gPingData) {
        return;
      }

      LateWritesSingleton.renderLateWrites(gPingData.payload.lateWrites);
  }, false);

  // Clicking on the section name will toggle its state
  let sectionHeaders = document.getElementsByClassName("section-name");
  for (let sectionHeader of sectionHeaders) {
    sectionHeader.addEventListener("click", toggleSection, false);
  }

  // Clicking on the "toggle" text will also toggle section's state
  let toggleLinks = document.getElementsByClassName("toggle-caption");
  for (let toggleLink of toggleLinks) {
    toggleLink.addEventListener("click", toggleSection, false);
  }
}

function onLoad() {
  window.removeEventListener("load", onLoad);

  // Set the text in the page header
  setupPageHeader();

  // Set up event listeners
  setupListeners();

  // Render settings.
  Settings.render();

  // Restore sections states
  let stateboxes = document.getElementsByClassName("statebox");
  for (let box of stateboxes) {
    if (box.checked) { // Was open. Will still display as empty if not has-data
        box.parentElement.classList.add("expanded");
    }
  }

  // Update ping data when async Telemetry init is finished.
  Telemetry.asyncFetchTelemetryData(() => PingPicker.update());
}

var LateWritesSingleton = {
  renderHeader: function LateWritesSingleton_renderHeader(aIndex) {
    StackRenderer.renderHeader("late-writes", [aIndex + 1]);
  },

  renderLateWrites: function LateWritesSingleton_renderLateWrites(lateWrites) {
    setHasData("late-writes-section", !!lateWrites);
    if (!lateWrites) {
      return;
    }

    let stacks = lateWrites.stacks;
    let memoryMap = lateWrites.memoryMap;
    StackRenderer.renderStacks('late-writes', stacks, memoryMap,
                               LateWritesSingleton.renderHeader);
  }
};

/**
 * Helper function for sorting the startup milestones in the Simple Measurements
 * section into temporal order.
 *
 * @param aSimpleMeasurements Telemetry ping's "Simple Measurements" data
 * @return Sorted measurements
 */
function sortStartupMilestones(aSimpleMeasurements) {
  const telemetryTimestamps = TelemetryTimestamps.get();
  let startupEvents = Services.startup.getStartupInfo();
  delete startupEvents['process'];

  function keyIsMilestone(k) {
    return (k in startupEvents) || (k in telemetryTimestamps);
  }

  let sortedKeys = Object.keys(aSimpleMeasurements);

  // Sort the measurements, with startup milestones at the front + ordered by time
  sortedKeys.sort(function keyCompare(keyA, keyB) {
    let isKeyAMilestone = keyIsMilestone(keyA);
    let isKeyBMilestone = keyIsMilestone(keyB);

    // First order by startup vs non-startup measurement
    if (isKeyAMilestone && !isKeyBMilestone)
      return -1;
    if (!isKeyAMilestone && isKeyBMilestone)
      return 1;
    // Don't change order of non-startup measurements
    if (!isKeyAMilestone && !isKeyBMilestone)
      return 0;

    // If both keys are startup measurements, order them by value
    return aSimpleMeasurements[keyA] - aSimpleMeasurements[keyB];
  });

  // Insert measurements into a result object in sort-order
  let result = {};
  for (let key of sortedKeys) {
    result[key] = aSimpleMeasurements[key];
  }

  return result;
}

function renderProcessList(ping, selectEl) {
  removeAllChildNodes(selectEl);
  let option = document.createElement("option");
  option.appendChild(document.createTextNode("parent"));
  option.setAttribute("value", "");
  option.selected = true;
  selectEl.appendChild(option);

  if (!("processes" in ping.payload)) {
    selectEl.disabled = true;
    return;
  }
  selectEl.disabled = false;

  for (let process of Object.keys(ping.payload.processes)) {
    // TODO: parent hgrams are on root payload, not in payload.processes.parent
    // When/If that gets moved, you'll need to remove this:
    if (process === "parent") {
      continue;
    }
    option = document.createElement("option");
    option.appendChild(document.createTextNode(process));
    option.setAttribute("value", process);
    selectEl.appendChild(option);
  }
}

function renderPayloadList(ping) {
  // Rebuild the payload select with options:
  //   Parent Payload (selected)
  //   Child Payload 1..ping.payload.childPayloads.length
  let listEl = document.getElementById("choose-payload");
  removeAllChildNodes(listEl);

  let option = document.createElement("option");
  let text = bundle.GetStringFromName("parentPayload");
  let content = document.createTextNode(text);
  let payloadIndex = 0;
  option.appendChild(content);
  option.setAttribute("value", payloadIndex++);
  option.selected = true;
  listEl.appendChild(option);

  if (!ping.payload.childPayloads) {
    listEl.disabled = true;
    return
  }
  listEl.disabled = false;

  for (; payloadIndex <= ping.payload.childPayloads.length; ++payloadIndex) {
    option = document.createElement("option");
    text = bundle.formatStringFromName("childPayloadN", [payloadIndex], 1);
    content = document.createTextNode(text);
    option.appendChild(content);
    option.setAttribute("value", payloadIndex);
    listEl.appendChild(option);
  }
}

function toggleElementHidden(element, isHidden) {
  if (isHidden) {
    element.classList.add("hidden");
  } else {
    element.classList.remove("hidden");
  }
}

function togglePingSections(isMainPing) {
  // We always show the sections that are "common" to all pings.
  // The raw payload section is only used for pings other than "main" and "saved-session".
  let commonSections = new Set(["general-data-section", "environment-data-section"]);
  let otherPingSections = new Set(["raw-payload-section"]);

  let elements = document.getElementById("structured-ping-data-section").children;
  for (let section of elements) {
    if (commonSections.has(section.id)) {
      continue;
    }

    let showElement = isMainPing != otherPingSections.has(section.id);
    toggleElementHidden(section, !showElement);
  }
}

function displayPingData(ping, updatePayloadList = false) {
  gPingData = ping;

  // Render raw ping data.
  let pre = document.getElementById("raw-ping-data");
  pre.textContent = JSON.stringify(gPingData, null, 2);

  // Update the structured data rendering.
  const keysHeader = bundle.GetStringFromName("keysHeader");
  const valuesHeader = bundle.GetStringFromName("valuesHeader");

  // Update the payload list and process lists
  if (updatePayloadList) {
    renderPayloadList(ping);
    renderProcessList(ping, document.getElementById("histograms-processes"));
    renderProcessList(ping, document.getElementById("keyed-histograms-processes"));
  }

  // Show general data.
  GeneralData.render(ping);

  // Show environment data.
  EnvironmentData.render(ping);

  // We only have special rendering code for the payloads from "main" pings.
  // For any other pings we just render the raw JSON payload.
  let isMainPing = (ping.type == "main" || ping.type == "saved-session");
  togglePingSections(isMainPing);

  if (!isMainPing) {
    RawPayload.render(ping);
    return;
  }

  // Show telemetry log.
  TelLog.render(ping);

  // Show slow SQL stats
  SlowSQL.render(ping);

  // Show chrome hang stacks
  ChromeHangs.render(ping);

  // Render Addon details.
  AddonDetails.render(ping);

  // Select payload to render
  let payloadSelect = document.getElementById("choose-payload");
  let payloadOption = payloadSelect.selectedOptions.item(0);
  let payloadIndex = payloadOption.getAttribute("value");

  let payload = ping.payload;
  if (payloadIndex > 0) {
    payload = ping.payload.childPayloads[payloadIndex - 1];
  }

  // Show thread hang stats
  ThreadHangStats.render(payload);

  // Show simple measurements
  let simpleMeasurements = sortStartupMilestones(payload.simpleMeasurements);
  let hasData = Object.keys(simpleMeasurements).length > 0;
  setHasData("simple-measurements-section", hasData);
  let simpleSection = document.getElementById("simple-measurements");
  removeAllChildNodes(simpleSection);

  if (hasData) {
    simpleSection.appendChild(KeyValueTable.render(simpleMeasurements,
                                                   keysHeader, valuesHeader));
  }

  LateWritesSingleton.renderLateWrites(payload.lateWrites);

  // Show basic session info gathered
  hasData = Object.keys(ping.payload.info).length > 0;
  setHasData("session-info-section", hasData);
  let infoSection = document.getElementById("session-info");
  removeAllChildNodes(infoSection);

  if (hasData) {
    infoSection.appendChild(KeyValueTable.render(ping.payload.info,
                                                 keysHeader, valuesHeader));
  }

  // Show scalar data.
  Scalars.render(payload);
  KeyedScalars.render(payload);

  // Show histogram data
  let hgramDiv = document.getElementById("histograms");
  removeAllChildNodes(hgramDiv);

  let histograms = payload.histograms;

  let hgramsSelect = document.getElementById("histograms-processes");
  let hgramsOption = hgramsSelect.selectedOptions.item(0);
  let hgramsProcess = hgramsOption.getAttribute("value");
  if (hgramsProcess &&
      "processes" in ping.payload &&
      hgramsProcess in ping.payload.processes) {
    histograms = ping.payload.processes[hgramsProcess].histograms;
  }

  hasData = Object.keys(histograms).length > 0;
  setHasData("histograms-section", hasData || hgramsSelect.options.length);

  if (hasData) {
    for (let [name, hgram] of Object.entries(histograms)) {
      Histogram.render(hgramDiv, name, hgram, {unpacked: true});
    }

    let filterBox = document.getElementById("histograms-filter");
    filterBox.addEventListener("input", Histogram.histogramFilterChanged, false);
    if (filterBox.value.trim() != "") { // on load, no need to filter if empty
      Histogram.filterHistograms(hgramDiv, filterBox.value);
    }

    setHasData("histograms-section", true);
  }

  // Show keyed histogram data
  let keyedDiv = document.getElementById("keyed-histograms");
  removeAllChildNodes(keyedDiv);

  let keyedHistograms = payload.keyedHistograms;

  let keyedHgramsSelect = document.getElementById("keyed-histograms-processes");
  let keyedHgramsOption = keyedHgramsSelect.selectedOptions.item(0);
  let keyedHgramsProcess = keyedHgramsOption.getAttribute("value");
  if (keyedHgramsProcess &&
      "processes" in ping.payload &&
      keyedHgramsProcess in ping.payload.processes) {
    keyedHistograms = ping.payload.processes[keyedHgramsProcess].keyedHistograms;
  }

  setHasData("keyed-histograms-section", keyedHgramsSelect.options.length);
  if (keyedHistograms) {
    let hasData = false;
    for (let [id, keyed] of Object.entries(keyedHistograms)) {
      if (Object.keys(keyed).length > 0) {
        hasData = true;
        KeyedHistogram.render(keyedDiv, id, keyed, {unpacked: true});
      }
    }
    setHasData("keyed-histograms-section", hasData || keyedHgramsSelect.options.length);
  }

  // Show event data.
  Events.render(payload);

  // Show addon histogram data
  let addonDiv = document.getElementById("addon-histograms");
  removeAllChildNodes(addonDiv);

  let addonHistogramsRendered = false;
  let addonData = payload.addonHistograms;
  if (addonData) {
    for (let [addon, histograms] of Object.entries(addonData)) {
      for (let [name, hgram] of Object.entries(histograms)) {
        addonHistogramsRendered = true;
        Histogram.render(addonDiv, addon + ": " + name, hgram, {unpacked: true});
      }
    }
  }

  setHasData("addon-histograms-section", addonHistogramsRendered);
}

window.addEventListener("load", onLoad, false);