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

// Docs: https://devdocs.io/d3~3/
Services.scriptloader.loadSubScript(
  "chrome://global/content/third_party/d3/d3.js"
);
const d3 = this.d3;

const { AppConstants } = ChromeUtils.importESModule(
  "resource://gre/modules/AppConstants.sys.mjs"
);

const METRIC_DATA = {};
let MAPPED_METRIC_DATA = [];
let FILTERED_METRIC_DATA = [];
let LIMITED_METRIC_DATA = [];
let LIMIT_OFFSET = 0;
let LIMIT_COUNT = 200;
let METRIC_DATA_INITIALIZED = false;
const INVALID_VALUE_REASONS = {
  DUAL_LABELED_METRIC: 0,
  UNKNOWN_METRIC: 1,
};
const SIMPLE_TYPES = {
  Boolean: "Boolean",
  String: "String",
  StringList: "StringList",
  Text: "Text",
  Counter: "Counter",
};
const SELECTED_METRICS = [];
let DOCUMENT_BODY_SEL = undefined;

function updatePrefsAndDefines() {
  let upload = Services.prefs.getBoolPref(
    "datareporting.healthreport.uploadEnabled"
  );
  document.l10n.setAttributes(
    document.querySelector("[data-l10n-id='about-glean-data-upload']"),
    "about-glean-data-upload",
    {
      "data-upload-pref-value": upload,
    }
  );
  let port = Services.prefs.getIntPref("telemetry.fog.test.localhost_port");
  document.l10n.setAttributes(
    document.querySelector("[data-l10n-id='about-glean-local-port']"),
    "about-glean-local-port",
    {
      "local-port-pref-value": port,
    }
  );
  document.l10n.setAttributes(
    document.querySelector("[data-l10n-id='about-glean-glean-android']"),
    "about-glean-glean-android",
    { "glean-android-define-value": AppConstants.MOZ_GLEAN_ANDROID }
  );
  document.l10n.setAttributes(
    document.querySelector("[data-l10n-id='about-glean-moz-official']"),
    "about-glean-moz-official",
    { "moz-official-define-value": AppConstants.MOZILLA_OFFICIAL }
  );

  // Knowing what we know, and copying logic from viaduct_uploader.rs,
  // (which is documented in Preferences and Defines),
  // tell the fine user whether and why upload is disabled.
  let uploadMessageEl = document.getElementById("upload-status");
  let uploadL10nId = "about-glean-upload-enabled";
  if (!upload) {
    uploadL10nId = "about-glean-upload-disabled";
  } else if (port < 0 || (port == 0 && !AppConstants.MOZILLA_OFFICIAL)) {
    uploadL10nId = "about-glean-upload-fake-enabled";
    // This message has a link to the Glean Debug Ping Viewer in it.
    // We must add the anchor element now so that Fluent can match it.
    let a = document.createElement("a");
    a.href = "https://debug-ping-preview.firebaseapp.com/";
    a.setAttribute("data-l10n-name", "glean-debug-ping-viewer");
    uploadMessageEl.appendChild(a);
  } else if (port > 0) {
    uploadL10nId = "about-glean-upload-enabled-local";
  }
  document.l10n.setAttributes(uploadMessageEl, uploadL10nId);
}

function camelToKebab(str) {
  let out = "";
  for (let i = 0; i < str.length; i++) {
    let c = str.charAt(i);
    if (c == c.toUpperCase()) {
      out += "-";
      c = c.toLowerCase();
    }
    out += c;
  }
  return out;
}

// I'm consciously omitting "deletion-request" until someone can come up with
// a use-case for sending it via about:glean.
const GLEAN_BUILTIN_PINGS = ["metrics", "events", "baseline"];
const NO_PING = "(don't submit any ping)";

function refillPingNames() {
  const builtInGroup = document.getElementById("builtin-pings");
  const customGroup = document.getElementById("custom-pings");

  // Add built-in ping options to the dropdown.
  GLEAN_BUILTIN_PINGS.forEach(id => {
    const option = document.createElement("option");
    option.value = id;
    option.textContent = camelToKebab(id);
    builtInGroup.appendChild(option);
  });

  // Add "(don't submit any ping)" as last built-in option in the dropdown.
  const noPingOption = document.createElement("option");
  noPingOption.value = NO_PING;
  document.l10n.setAttributes(noPingOption, "about-glean-no-ping-label");
  builtInGroup.appendChild(noPingOption);

  // Add alpha sorted custom ping options to the dropdown.
  Object.keys(GleanPings)
    .map(id => ({ id, label: camelToKebab(id) }))
    .sort((a, b) => a.label.localeCompare(b.label))
    .forEach(({ label }) => {
      const option = document.createElement("option");
      option.textContent = label;
      customGroup.appendChild(option);
    });
}

// If there's been a previous tag, use it.
// If not, be _slightly_ clever and derive a default one from the profile dir.
function fillDebugTag() {
  const DEBUG_TAG_PREF = "telemetry.fog.aboutGlean.debugTag";
  let debugTag;
  if (Services.prefs.prefHasUserValue(DEBUG_TAG_PREF)) {
    debugTag = Services.prefs.getStringPref(DEBUG_TAG_PREF);
  } else {
    const debugTagPrefix = "about-glean-";
    const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
    let charSum = Array.from(profileDir).reduce(
      (prev, cur) => prev + cur.charCodeAt(0),
      0
    );

    debugTag = debugTagPrefix + (charSum % 1000);
  }

  let tagInput = document.getElementById("tag-pings");
  tagInput.value = debugTag;
  const updateDebugTagValues = () => {
    document.l10n.setAttributes(
      document.querySelector(
        "[data-l10n-id='about-glean-label-for-controls-submit']"
      ),
      "about-glean-label-for-controls-submit",
      { "debug-tag": tagInput.value }
    );
    const GDPV_ROOT = "https://debug-ping-preview.firebaseapp.com/pings/";
    let gdpvLink = document.querySelector(
      "[data-l10n-name='gdpv-tagged-pings-link']"
    );
    gdpvLink.href = GDPV_ROOT + tagInput.value;
  };
  tagInput.addEventListener("change", () => {
    Services.prefs.setStringPref(DEBUG_TAG_PREF, tagInput.value);
    updateDebugTagValues();
  });
  updateDebugTagValues();
}

function showTab(button) {
  let current_tab = document.querySelector(".active");
  let category = button.getAttribute("id").substring("category-".length);
  let content = document.getElementById(category);
  if (current_tab == content) {
    return;
  }
  current_tab.classList.remove("active");
  current_tab.hidden = true;
  content.classList.add("active");
  content.hidden = false;
  let current_button = document.querySelector("[selected=true]");
  current_button.removeAttribute("selected");
  button.setAttribute("selected", "true");

  if (category == "metrics-table") {
    // Init base level metric data
    initializeMetricData();

    const table = document.getElementById("metrics-table-instance");
    table.removeAttribute("hidden");

    // Map the metric data into a better defined type structure
    MAPPED_METRIC_DATA = Object.entries(METRIC_DATA).flatMap(
      ([category, metrics]) =>
        Object.entries(metrics).map(([name, metric]) => ({
          category,
          name,
          fullName: `${category}.${name}`,
          ...metric,
        }))
    );
    updateFilteredMetricData(
      document.getElementById("filter-metrics").value.toLowerCase()
    );
    updateTable();
  }
}

function handleRedesign() {
  const categories = document.getElementById("categories");
  const div = document.createElement("div");
  div.id = "category-metrics-table";
  div.className = "category";
  div.setAttribute("role", "menuitem");
  div.setAttribute("tabindex", 0);
  const span = document.createElement("span");
  span.className = "category-name";
  span.setAttribute("data-l10n-id", "about-glean-category-metrics-table");
  div.appendChild(span);
  categories.appendChild(div);

  /**
   * Handle metric filter input.
   *
   * This uses a timeout to debounce the events down to 200ms.
   * Instead of updating the DOM every time the input changes, it'll only update when the input hasn't changed in the last 200ms since it last changed.
   */
  let inputTimeout = undefined;
  document.getElementById("filter-metrics").addEventListener("input", e => {
    clearTimeout(inputTimeout);
    inputTimeout = setTimeout(() => {
      updateFilteredMetricData(e.target.value.toLowerCase() ?? "");
    }, 200);
  });

  // Handle loading all metric data
  document.getElementById("load-all").addEventListener("click", () => {
    MAPPED_METRIC_DATA.forEach(datum => {
      updateDatum(datum);
    });
    updateTable();
  });
}

function onLoad() {
  let menu = document.getElementById("categories");
  menu.addEventListener("click", function click(e) {
    if (e.target && e.target.parentNode == menu) {
      showTab(e.target);
    }
  });
  menu.addEventListener("keydown", function keypress(e) {
    if (
      e.target &&
      e.target.parentNode == menu &&
      (e.key == " " || e.key == "Enter")
    ) {
      showTab(e.target);
    }
  });
  showTab(document.getElementById("category-about-glean"));

  updatePrefsAndDefines();
  refillPingNames();
  fillDebugTag();
  document.getElementById("controls-submit").addEventListener("click", () => {
    let tag = document.getElementById("tag-pings").value;
    let log = document.getElementById("log-pings").checked;
    let ping = document.getElementById("ping-names").value;
    let feedbackToast = document.getElementById("feedback");

    Services.fog.setLogPings(log);
    Services.fog.setTagPings(tag);

    if (ping != NO_PING) {
      Services.fog.sendPing(ping);
      feedbackToast.setAttribute(
        "data-l10n-id",
        "about-glean-feedback-settings-and-ping"
      );
    } else {
      feedbackToast.setAttribute(
        "data-l10n-id",
        "about-glean-feedback-settings-only"
      );
    }

    feedbackToast.style.visibility = "visible";
    setTimeout(() => {
      feedbackToast.style.visibility = "hidden";
    }, 3000);
  });

  handleRedesign();

  DOCUMENT_BODY_SEL = d3.select(document.body);

  document
    .getElementById("metrics-table-settings-button")
    .addEventListener("click", () => {
      const settingsDiv = document.getElementById("metrics-table-settings");
      if (settingsDiv.getAttribute("hidden")) {
        settingsDiv.removeAttribute("hidden");
      } else {
        settingsDiv.setAttribute("hidden", true);
      }
      settingsChanged();
    });
  initMetricsSettings();

  document.getElementById("export-data").addEventListener("click", () => {
    exportData();
  });
}

/**
 * Initializes the base level metric data.
 *
 * Should only be able to be called once.
 */
function initializeMetricData() {
  if (METRIC_DATA_INITIALIZED) {
    return;
  }
  for (let [category, metrics] of Object.entries(Glean)) {
    for (let [metricName, metric] of Object.entries(metrics)) {
      // Trim "Glean" from the constructor names (e.g. "GleanBoolean" -> "Boolean").
      let constructorName = metric.constructor.name.replace("Glean", "");
      // For labeled metrics, get their submetrics' constructor names and append it
      if (constructorName == "Labeled") {
        constructorName += metric.__other__.constructor.name.replace(
          "Glean",
          ""
        );
      }
      if (!METRIC_DATA[category]) {
        METRIC_DATA[category] = {};
      }
      METRIC_DATA[category][metricName] = {
        type: constructorName,
        value: undefined,
        metric,
      };
    }
  }
  METRIC_DATA_INITIALIZED = true;
}

function updateButtonsSelection(selection) {
  selection.attr("data-l10n-id", d =>
    d.watching ? "about-glean-button-unwatch" : "about-glean-button-watch"
  );
}

function createOrUpdateLabeledHistogram(selection, datum) {
  const keysAndValues = Object.entries(datum.value || {}).map(
    ([key, value]) => [
      key,
      {
        fullName: datum.fullName + `.${key}`,
        value,
      },
    ]
  );

  selection.selectAll("*").remove();
  if (keysAndValues.length === 0) {
    selection
      .append("p")
      .attr("data-l10n-id", "about-glean-no-data-to-display");
    return;
  }
  for (let entry of keysAndValues.toSorted((a, b) =>
    d3.ascending(a[1].fullName, b[1].fullName)
  )) {
    const div = selection
      .append("div")
      .classed({ "keyed-chart": true })
      .attr("id", `${entry[0]}-container`)
      .attr("data-d3-key", entry[0]);
    div.append("h2").text(entry[0]);
    const svgContainer = div.append("div");
    createOrUpdateHistogram(svgContainer, entry[1]);
  }
}

function createOrUpdateHistogram(selection, datum) {
  const values = Object.entries(datum.value?.values || {}).map((d, i) => [
    ...d,
    i,
  ]);

  if (!values || values.length === 0) {
    selection.select("p")?.remove();
    selection
      .append("p")
      .attr("data-l10n-id", "about-glean-no-data-to-display");
    selection.select("svg")?.remove();
    return;
  }
  selection.select("p")?.remove();

  const chartSettings = {
    boxPadding: 5,
    chartMax: 150,
    leftPadding: 20,
    chartPadding: 50,
    scaledMax: 110,
    ...metricsTableSettings.histograms,
  };
  const max = values.map(d => d[1]).sort((a, b) => b - a)[0],
    keyMax = values.map(d => d[0]).sort((a, b) => b - a)[0],
    boxWidth = Math.max(`${Math.max(max, keyMax)}`.length * 10, 30),
    denom = max / chartSettings.scaledMax;

  let hist = selection.select(`svg[data-d3-datum='${datum.fullName}']`);
  if (hist.empty()) {
    hist = selection
      .append("svg")
      .classed({ histogram: true })
      .attr("data-d3-datum", datum.fullName)
      .attr(
        "width",
        values.length * (boxWidth + chartSettings.boxPadding) +
          chartSettings.chartPadding +
          chartSettings.leftPadding
      )
      .attr("height", chartSettings.chartMax + chartSettings.chartPadding);
  }

  let boxesContainer = hist.select("g.boxes");
  if (boxesContainer.empty()) {
    boxesContainer = hist.append("g").classed({ boxes: true });
  }

  const boxes = boxesContainer.selectAll("g").data(values, d => d);

  const newBoxes = boxes.enter().append("g").attr("tabindex", 0);
  newBoxes.append("rect").attr("width", boxWidth);
  newBoxes.append("text").attr("data-d3-role", "y");
  newBoxes.append("text").attr("data-d3-role", "x");

  const xFn = index =>
    boxWidth * index +
    chartSettings.boxPadding * index +
    chartSettings.leftPadding;
  const yFn = yv =>
    Math.abs(Math.max(yv / denom, 1) - chartSettings.scaledMax) +
    (chartSettings.chartMax - chartSettings.scaledMax);

  boxes
    .selectAll("rect")
    .attr("height", d => Math.max(d[1] / denom, 1))
    .attr("x", (_, __, i) => xFn(i))
    .attr("y", d => yFn(d[1]));
  boxes
    .selectAll("text[data-d3-role=y]")
    .attr("x", (_, __, i) => xFn(i))
    .attr("y", d => yFn(d[1]) - 5)
    .text(d => d[1]);
  boxes
    .selectAll("text[data-d3-role=x]")
    .attr("x", d => xFn(d[2]))
    .attr("y", chartSettings.chartMax + 20)
    .text(d => d[0]);

  function focusStart(_) {
    this.classList.add("hovered");
  }

  function focusEnd(_) {
    this.classList.remove("hovered");
  }

  boxes
    .attr("data-d3-box", d => d[0])
    .on("focusin", focusStart)
    .on("mouseover", focusStart)
    .on("focusout", focusEnd)
    .on("mouseout", focusEnd);

  boxes.exit().remove();
}

function createOrUpdateEventChart(selection, datum) {
  const values = (datum.value || []).map((d, i) => ({
    ...d,
    index: i,
    fullName: datum.fullName,
  }));

  if (!values || values.length === 0) {
    selection.select("p")?.remove();
    selection
      .append("p")
      .attr("data-l10n-id", "about-glean-no-data-to-display");
    selection.select("svg")?.remove();
    return;
  }
  selection.select("p")?.remove();

  const chartSettings = {
    height: 75,
    width: 500,
    chartPadding: 50,
    circleRadius: 6,
    verticalLineXOffset: 10,
    verticalLineYOffset: 10,
    ...metricsTableSettings.timelines,
  };
  const max = values.map(d => d.timestamp).sort((a, b) => b - a)[0],
    min = values.map(d => d.timestamp).sort((a, b) => a - b)[0];

  let diagram = selection.select(`svg[data-d3-datum='${datum.fullName}']`);
  if (diagram.empty()) {
    diagram = selection
      .append("svg")
      .attr("data-d3-datum", datum.fullName)
      .classed({ timeline: true });
  }
  diagram
    .attr("width", chartSettings.width)
    .attr("height", chartSettings.height);

  let lineAcross = diagram.select("line[data-d3-role='across']");
  if (lineAcross.empty()) {
    lineAcross = diagram.append("line").attr("data-d3-role", "across");
  }
  lineAcross
    .attr("x1", chartSettings.chartPadding)
    .attr("y1", chartSettings.height / 2)
    .attr("x2", chartSettings.width - chartSettings.chartPadding)
    .attr("y2", chartSettings.height / 2);

  let leftLineThrough = diagram.select("line[data-d3-role='left-through']");
  if (leftLineThrough.empty()) {
    leftLineThrough = diagram
      .append("line")
      .attr("data-d3-role", "left-through");
  }
  leftLineThrough
    .attr("x1", chartSettings.chartPadding + chartSettings.verticalLineXOffset)
    .attr("y1", chartSettings.height / 2 - chartSettings.verticalLineYOffset)
    .attr("x2", chartSettings.chartPadding + chartSettings.verticalLineXOffset)
    .attr("y2", chartSettings.height / 2 + chartSettings.verticalLineYOffset);

  let rightLineThrough = diagram.select("line[data-d3-role='right-through']");
  if (rightLineThrough.empty()) {
    rightLineThrough = diagram
      .append("line")
      .attr("data-d3-role", "right-through");
  }
  rightLineThrough
    .attr(
      "x1",
      chartSettings.width -
        chartSettings.chartPadding -
        chartSettings.verticalLineXOffset
    )
    .attr("y1", chartSettings.height / 2 - chartSettings.verticalLineYOffset)
    .attr(
      "x2",
      chartSettings.width -
        chartSettings.chartPadding -
        chartSettings.verticalLineXOffset
    )
    .attr("y2", chartSettings.height / 2 + chartSettings.verticalLineYOffset);

  let code = selection.select("pre");
  if (code.empty()) {
    code = selection.append("pre").classed({ withChart: true }).append("code");
  } else {
    code = code.select("code");
  }

  const xFn = d3.scale
    .linear()
    .domain([min, max])
    .range([
      chartSettings.verticalLineXOffset + chartSettings.chartPadding,
      chartSettings.width -
        chartSettings.chartPadding -
        chartSettings.verticalLineXOffset,
    ]);

  let eventsContainer = diagram.select("g.events");
  if (eventsContainer.empty()) {
    eventsContainer = diagram.append("g").classed({ events: true });
  }

  const events = eventsContainer
    .selectAll("g.event")
    .data(values, d => `${d.fullName}-${d.index}-${d.timestamp}`);

  const newEvents = events
    .enter()
    .append("g")
    .classed({ event: true })
    .attr("tabindex", 0);

  newEvents.append("circle");

  function focusStart(_) {
    this.classList.add("hovered");
  }

  function focusEnd(_) {
    this.classList.remove("hovered");
  }

  function select(_) {
    const dataPoint = this.__data__;
    code.text(prettyPrint(dataPoint.extra));

    document.querySelectorAll("g.event.selected").forEach(el => {
      el.classList.remove("selected");
      el.querySelector("text")?.remove();
    });
    this.classList.add("selected");

    const text = this.appendChild(
      document.createElementNS("http://www.w3.org/2000/svg", "text")
    );
    text.setAttribute("y", chartSettings.height / 2 + 25);
    text.setAttribute(
      "x",
      xFn(dataPoint.timestamp) - `${dataPoint.timestamp}`.length * 4.5
    );
    text.textContent = dataPoint.timestamp;
  }

  events.attr("data-d3-datum", d => `${d.fullName}-${d.index}-${d.timestamp}`);

  events
    .selectAll("circle")
    .attr("cy", chartSettings.height / 2)
    .attr("cx", d => xFn(d.timestamp))
    .attr("r", chartSettings.circleRadius);

  events
    .on("focusin", select)
    .on("mouseover", focusStart)
    .on("focusout", focusEnd)
    .on("mouseout", focusEnd);

  events.exit().remove();
}

const METRICS_TABLE_SETTINGS_KEY = "about-glean-metrics-table-settings";
/**
 * When adding new fields to the metrics table settings,
 * a corresponding element matching the query selector
 * `[data-form-control=<key>]` must be present in the DOM.
 */
const metricsTableSettings = {
  hideEmptyValueRows: false,
  histograms: {
    boxPadding: 5,
    chartMax: 150,
    leftPadding: 20,
    chartPadding: 50,
    scaledMax: 110,
  },
  timelines: {
    height: 75,
    width: 500,
    chartPadding: 50,
    circleRadius: 6,
    verticalLineXOffset: 10,
    verticalLineYOffset: 10,
  },
};

function initMetricsSettings() {
  const handleSetting = (obj, key, parent) => {
    let element = parent.querySelector(`[data-form-control='${key}']`);
    let valueFn = e => e.target.value;
    if (!element && typeof obj[key] !== "object") {
      console.error(
        new Error(
          `Unable to find form control with key '${key}' in the parent element`
        ),
        parent
      );
      return;
    }
    switch (typeof obj[key]) {
      case "boolean":
        valueFn = e => e.target.checked;
        obj[key] = valueFn({ target: element });
        break;
      case "object":
        element = parent.querySelector(`[data-form-group='${key}']`);
        if (!element) {
          console.error(
            new Error(
              `Unable to find form control with key '${key}' in the parent element`
            ),
            parent
          );
          return;
        }
        for (const subKey of Object.keys(obj[key])) {
          handleSetting(obj[key], subKey, element);
        }
        break;
      case "number":
        valueFn = e => parseInt(e.target.value);
      // eslint-disable-next-line no-fallthrough
      default:
        if (element.type !== typeof obj[key]) {
          console.warn(
            new Error(
              `Form control input type does not match JavaScript value type ${typeof obj[key]}`
            )
          );
        }
        if (valueFn({ target: element })) {
          obj[key] = valueFn({ target: element });
        } else {
          element.value = obj[key];
        }
    }
    element.addEventListener("input", handleSettingChange(obj, valueFn));
  };

  for (const key of Object.keys(metricsTableSettings)) {
    handleSetting(
      metricsTableSettings,
      key,
      document.getElementById("metrics-table-settings")
    );
  }
}

function handleSettingChange(obj, valueFn) {
  return e => {
    obj[e.target.getAttribute("data-form-control")] = valueFn(e);
    settingsChanged();
  };
}

function settingsChanged() {
  createOrUpdateHistogram(
    d3.select("#metrics-table-settings-histogram-example"),
    {
      fullName: "histogram-example",
      value: {
        values: {
          0: 1,
          1: 5,
          2: 4,
          3: 0,
        },
      },
    }
  );

  createOrUpdateEventChart(
    d3.select("#metrics-table-settings-timeline-example"),
    {
      fullName: "timeline-example",
      value: [
        {
          timestamp: 0,
          extra: {
            value: 1,
          },
        },
        {
          timestamp: 1,
          extra: {
            value: 2,
          },
        },
        {
          timestamp: 4,
          extra: {
            value: 3,
          },
        },
        {
          timestamp: 8,
          extra: {
            value: 4,
          },
        },
      ],
    }
  );

  updateTable();
}

function updateValueSelection(selection) {
  // Set the `data-l10n-id` attribute to the appropriate warning if the value is invalid, otherwise
  // unset it by returning `null`.
  selection
    ?.attr("data-l10n-id", d => {
      switch (d.invalidValue) {
        case INVALID_VALUE_REASONS.DUAL_LABELED_METRIC:
          return "about-glean-dual-labeled-metric-warning";
        case INVALID_VALUE_REASONS.UNKNOWN_METRIC:
          return "about-glean-unknown-metric-type-warning";
        default:
          return null;
      }
    })
    ?.each(function (datum) {
      if (datum.loaded) {
        let codeSelection = d3.select(this).select("pre>code");
        switch (datum.type) {
          case "Event":
            createOrUpdateEventChart(d3.select(this), datum);
            break;
          case "CustomDistribution":
          case "MemoryDistribution":
          case "TimingDistribution":
            createOrUpdateHistogram(d3.select(this), datum);
            break;
          case "LabeledCustomDistribution":
          case "LabeledMemoryDistribution":
          case "LabeledTimingDistribution":
            createOrUpdateLabeledHistogram(d3.select(this), datum);
            break;
          default:
            if (codeSelection.empty()) {
              codeSelection = d3.select(this).append("pre").append("code");
            }
            codeSelection.text(prettyPrint(datum.value));
        }
      }
    });
}

/**
 * Updates a datum object with its value from `testGetValue`.
 *
 * @param {*} datum the datum object to update
 * @param {*} update update the table after updating the datum (defaults to `true`)
 */
function updateDatum(datum) {
  if (typeof datum.metric.testGetValue == "function") {
    try {
      datum.value = datum.metric.testGetValue();
      datum.error = undefined;
    } catch (e) {
      datum.error = e;
    }
    datum.loaded = true;
    datum.invalidValue = undefined;
  } else if (datum.type.includes("DualLabeled")) {
    datum.invalidValue = INVALID_VALUE_REASONS.DUAL_LABELED_METRIC;
  } else {
    datum.invalidValue = INVALID_VALUE_REASONS.UNKNOWN_METRIC;
  }

  updateValueSelection(
    DOCUMENT_BODY_SEL.select(
      `tr[data-d3-row="${datum.fullName}"]>td[data-d3-cell=value]`
    )
  );
}

/**
 * Prettifies a JSON value to make it render more nicely in the table.
 *
 * @param {*} jsonValue the JSON value to prettify
 * @returns a string containing the prettified JSON value in a pre+code
 */
function prettyPrint(jsonValue) {
  if (typeof jsonValue == "object") {
    jsonValue = Object.keys(jsonValue ?? {})
      .sort()
      .reduce((obj, key) => {
        obj[key] = jsonValue[key];
        return obj;
      }, {});
  }
  // from devtools/client/jsonview/json-viewer.mjs
  const pretty = JSON.stringify(
    jsonValue,
    (key, value) => {
      if (value?.type === Symbol("JSON_NUMBER")) {
        return JSON.rawJSON(value.source);
      }

      // By default, -0 will be stringified as `0`, so we need to handle it
      if (Object.is(value, -0)) {
        return JSON.rawJSON("-0");
      }

      return value;
    },
    "  "
  );
  return pretty;
}

function getDocsURL(datum) {
  const upperRegExp = /[A-Z]/;
  const app = "firefox_desktop";
  let category = datum.category;
  let index = category.search(upperRegExp);
  while (index != -1) {
    category = category.replace(
      upperRegExp,
      "_" + category[index].toLowerCase()
    );
    index = category.search(upperRegExp);
  }

  let name = datum.name;
  index = name.search(upperRegExp);
  while (index != -1) {
    name = name.replace(upperRegExp, "_" + name[index].toLowerCase());
    index = name.search(upperRegExp);
  }
  return `https://dictionary.telemetry.mozilla.org/apps/${app}/metrics/${category}_${name}`;
}

/**
 * Updates the `about:glean` metrics table body based on the data points in FILTERED_METRIC_DATA.
 */
function updateTable() {
  LIMITED_METRIC_DATA = FILTERED_METRIC_DATA.toSorted((a, b) =>
    d3.ascending(a.fullName, b.fullName)
  )
    // Filter out rows whose datum elements have either a) been loaded and have a value, or b) have not yet been loaded.
    .filter(d =>
      metricsTableSettings.hideEmptyValueRows
        ? (d.value !== undefined && d.value !== null) || !d.loaded
        : true
    )
    // Filter down to only the datum elements whose indexes fall in the limit offset+count.
    .filter((_, i) => i >= LIMIT_OFFSET && i < LIMIT_COUNT + LIMIT_OFFSET);

  // Let's talk about d3.js
  // `d3.select` is a rough equivalent to `document.querySelector`, but the resulting object(s) are things d3 knows how to manipulate.
  const tbody = DOCUMENT_BODY_SEL.select("#metrics-table-body");
  // Select all the `tr` elements within the previously selected `tbody` element.
  const rows = tbody
    .selectAll("tr")
    // Set the data for the `tr` elements to be the FILTERED_METRIC_DATA, keyed off the data element's full name
    .data(LIMITED_METRIC_DATA, d => d.fullName);

  // `.enter()` means this section determines how we handle new data elements in the array.
  // We class them and insert the appropriate data cells
  let newRows = rows
    .enter()
    .append("tr")
    .attr("data-d3-row", d => d.fullName)
    .classed({ "metric-row": true });

  const actions = newRows
    .append("td")
    .attr("data-d3-cell", "actions")
    .append("div");
  // Set the HTML content for the `category` and `name` cells, and store the name cells in-scope so we can
  // append our buttons to them.
  newRows
    .append("td")
    .attr("data-d3-cell", "category")
    .append("pre")
    .text(d => d.category);
  newRows
    .append("td")
    .attr("data-d3-cell", "name")
    .append("pre")
    .text(d => d.name);
  // Handle displaying the metric type.
  newRows
    .append("td")
    .attr("data-d3-cell", "type")
    .text(d => d.type);
  newRows.append("td").attr("data-d3-cell", "value");

  actions
    .append("button")
    .attr("data-l10n-id", "about-glean-button-load-value")
    .on("click", datum => updateDatum(datum));
  actions
    .append("button")
    .attr("data-l10n-id", "about-glean-button-dictionary-link")
    .classed({ primary: true })
    // On click, rewrite the metric category+name to snake-case, so we can link to the Glean dictionary.
    // TODO: add canonical_name field to metrics https://bugzilla.mozilla.org/show_bug.cgi?id=1983630
    .on("click", datum => {
      const url = getDocsURL(datum);
      window.open(url, "_blank").focus();
    });

  // Since `.enter` has been called on `rows` and we've handled new data points, everything
  // that touches `rows` from here on out will affect ALL elements, old and new.

  updateButtonsSelection(
    rows.selectAll("td[data-d3-cell=actions] button[data-d3-button=watch]")
  );
  // Handle the metric's value.
  updateValueSelection(rows.selectAll("td[data-d3-cell=value]"));

  // Sort the `tr` elements by full metric category+name.
  rows.sort((a, b) => d3.ascending(a.fullName, b.fullName));

  // Handle exiting data points by removing their elements.
  rows.exit().remove();

  // Manually trigger translation on the table, as DOM updates after the first application of the `data-l10n-id` will not translate.
  document.l10n.translateFragment(
    document.querySelector("#metrics-table-body")
  );
}

/**
 * Updates the FILTERED_METRIC_DATA value based on the provided `searchString`.
 *
 * @param {*} searchString the string by which the metric data will be filtered
 */
function updateFilteredMetricData(searchString) {
  if (!searchString) {
    FILTERED_METRIC_DATA = MAPPED_METRIC_DATA;
  } else {
    const simpleTypeValueSearch = datum => {
      if (!Object.values(SIMPLE_TYPES).includes(datum.type)) {
        return false;
      }
      switch (datum.type) {
        case SIMPLE_TYPES.Boolean:
          if (searchString == "true") {
            return datum.value === true;
          } else if (searchString == "false") {
            return datum.value === false;
          }
          return false;
        default:
          return false;
      }
    };
    FILTERED_METRIC_DATA = MAPPED_METRIC_DATA.filter(
      datum =>
        datum.category.toLowerCase().includes(searchString) ||
        datum.name.toLowerCase().includes(searchString) ||
        datum.type.toLowerCase().includes(searchString) ||
        simpleTypeValueSearch(datum)
    );
  }

  if (FILTERED_METRIC_DATA.length > LIMIT_COUNT + LIMIT_OFFSET) {
    const table = document.getElementById("metrics-table-instance");
    let scrollTimeout,
      scrollTimeoutIsCleared = true;
    table.addEventListener("scroll", el => {
      if (scrollTimeoutIsCleared) {
        scrollTimeout = setTimeout(
          ({ target }) => {
            clearTimeout(scrollTimeout);
            scrollTimeoutIsCleared = true;
            let changes = false;
            if (target.scrollTop < 1500) {
              if (LIMIT_COUNT >= 500 && LIMIT_OFFSET > 0) {
                LIMIT_OFFSET = LIMIT_OFFSET - 100 < 0 ? 0 : LIMIT_OFFSET - 100;
                changes = true;
              }
            } else if (target.scrollHeight - target.scrollTop < 1500) {
              if (LIMIT_COUNT >= 500) {
                if (
                  LIMIT_OFFSET + LIMIT_COUNT + 100 >
                  FILTERED_METRIC_DATA.length
                ) {
                  LIMIT_OFFSET = FILTERED_METRIC_DATA.length - LIMIT_COUNT;
                } else if (
                  LIMIT_OFFSET + LIMIT_COUNT <
                  FILTERED_METRIC_DATA.length - 100
                ) {
                  LIMIT_OFFSET += 100;
                }
              } else {
                LIMIT_COUNT += 100;
              }
              changes = true;
            }
            if (changes) {
              updateTable();
            }
          },
          10,
          el
        );
        scrollTimeoutIsCleared = false;
      }
    });
  } else {
    LIMIT_COUNT = 200;
    LIMIT_OFFSET = 0;
  }

  updateTable();
}

function exportData() {
  const data = MAPPED_METRIC_DATA.toSorted((a, b) =>
    d3.ascending(a.fullName, b.fullName)
  ).reduce(
    (accumulator, datum) => [
      ...accumulator,
      {
        name: `${datum.fullName}`,
        docs: getDocsURL(datum),
        value: datum.value,
      },
    ],
    []
  );
  const href = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(data))}`;
  const anchor = document.createElement("a");
  anchor.setAttribute("href", href);
  anchor.setAttribute(
    "download",
    `about-glean-export-${new Date().toJSON()}.json`
  );
  document.body.appendChild(anchor);
  anchor.click();
  anchor.remove();
}

window.addEventListener("load", onLoad);
