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

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

const SEARCH_TIMEOUT_MS = 100;
const SEARCH_AUTO_MIN_CHARACTERS = 3;

const GETTERS_BY_PREF_TYPE = {
  [Ci.nsIPrefBranch.PREF_BOOL]: "getBoolPref",
  [Ci.nsIPrefBranch.PREF_INT]: "getIntPref",
  [Ci.nsIPrefBranch.PREF_STRING]: "getStringPref",
};

const STRINGS_ADD_BY_TYPE = {
  Boolean: "about-config-pref-add-type-boolean",
  Number: "about-config-pref-add-type-number",
  String: "about-config-pref-add-type-string",
};

// Fluent limits the maximum length of placeables.
const MAX_PLACEABLE_LENGTH = 2500;

let gDefaultBranch = Services.prefs.getDefaultBranch("");
let gFilterPrefsTask = new DeferredTask(
  () => filterPrefs(),
  SEARCH_TIMEOUT_MS,
  0
);

/**
 * Maps the name of each preference in the back-end to its PrefRow object,
 * separating the preferences that actually exist. This is as an optimization to
 * avoid querying the preferences service each time the list is filtered.
 */
let gExistingPrefs = new Map();
let gDeletedPrefs = new Map();

/**
 * Also cache several values to improve the performance of common use cases.
 */
let gSortedExistingPrefs = null;
let gSearchInput = null;
let gShowOnlyModifiedCheckbox = null;
let gPrefsTable = null;

/**
 * Reference to the PrefRow currently being edited, if any.
 */
let gPrefInEdit = null;

/**
 * Lowercase substring that should be contained in the preference name.
 */
let gFilterString = null;

/**
 * RegExp that should be matched to the preference name.
 */
let gFilterPattern = null;

/**
 * True if we were requested to show all preferences.
 */
let gFilterShowAll = false;

class PrefRow {
  constructor(name, opts) {
    this.name = name;
    this.value = true;
    this.hidden = false;
    this.odd = false;
    this.editing = false;
    this.isAddRow = opts && opts.isAddRow;
    this.refreshValue();
  }

  refreshValue() {
    let prefType = Services.prefs.getPrefType(this.name);

    // If this preference has been deleted, we keep its last known value.
    if (prefType == Ci.nsIPrefBranch.PREF_INVALID) {
      this.hasDefaultValue = false;
      this.hasUserValue = false;
      this.isLocked = false;
      if (gExistingPrefs.has(this.name)) {
        gExistingPrefs.delete(this.name);
        gSortedExistingPrefs = null;
      }
      gDeletedPrefs.set(this.name, this);
      return;
    }

    if (!gExistingPrefs.has(this.name)) {
      gExistingPrefs.set(this.name, this);
      gSortedExistingPrefs = null;
    }
    gDeletedPrefs.delete(this.name);

    try {
      this.value = gDefaultBranch[GETTERS_BY_PREF_TYPE[prefType]](this.name);
      this.hasDefaultValue = true;
    } catch (ex) {
      this.hasDefaultValue = false;
    }
    this.hasUserValue = Services.prefs.prefHasUserValue(this.name);
    this.isLocked = Services.prefs.prefIsLocked(this.name);

    try {
      if (this.hasUserValue) {
        // This can throw for locked preferences without a default value.
        this.value = Services.prefs[GETTERS_BY_PREF_TYPE[prefType]](this.name);
      } else if (/^chrome:\/\/.+\/locale\/.+\.properties/.test(this.value)) {
        // We don't know which preferences should be read using getComplexValue,
        // so we use a heuristic to determine if this is a localized preference.
        // This can throw if there is no value in the localized files.
        this.value = Services.prefs.getComplexValue(
          this.name,
          Ci.nsIPrefLocalizedString
        ).data;
      }
    } catch (ex) {
      this.value = "";
    }
  }

  get type() {
    return this.value.constructor.name;
  }

  get exists() {
    return this.hasDefaultValue || this.hasUserValue;
  }

  get matchesFilter() {
    if (!this.matchesModifiedFilter) {
      return false;
    }

    return (
      gFilterShowAll ||
      (gFilterPattern && gFilterPattern.test(this.name)) ||
      (gFilterString && this.name.toLowerCase().includes(gFilterString))
    );
  }

  get matchesModifiedFilter() {
    const onlyShowModified = gShowOnlyModifiedCheckbox.checked;
    return !onlyShowModified || this.hasUserValue;
  }

  /**
   * Returns a reference to the table row element to be added to the document,
   * constructing and initializing it the first time this method is called.
   */
  getElement() {
    if (this._element) {
      return this._element;
    }

    this._element = document.createElement("tr");
    this._element._pref = this;

    let nameCell = document.createElement("th");
    let nameCellSpan = document.createElement("span");
    nameCell.appendChild(nameCellSpan);
    this._element.append(
      nameCell,
      (this.valueCell = document.createElement("td")),
      (this.editCell = document.createElement("td")),
      (this.resetCell = document.createElement("td"))
    );
    this.editCell.appendChild(
      (this.editButton = document.createElement("button"))
    );
    delete this.resetButton;

    nameCell.setAttribute("scope", "row");
    this.valueCell.className = "cell-value";
    this.editCell.className = "cell-edit";
    this.resetCell.className = "cell-reset";

    // Add <wbr> behind dots to prevent line breaking in random mid-word places.
    let parts = this.name.split(".");
    for (let i = 0; i < parts.length - 1; i++) {
      nameCellSpan.append(parts[i] + ".", document.createElement("wbr"));
    }
    nameCellSpan.append(parts[parts.length - 1]);

    this.refreshElement();

    return this._element;
  }

  refreshElement() {
    if (!this._element) {
      // No need to update if this preference was never added to the table.
      return;
    }

    if (this.exists && !this.editing) {
      // We need to place the text inside a "span" element to ensure that the
      // text copied to the clipboard includes all whitespace.
      let span = document.createElement("span");
      span.textContent = this.value;
      // We additionally need to wrap this with another "span" element to convey
      // the state to screen readers without affecting the visual presentation.
      span.setAttribute("aria-hidden", "true");
      let outerSpan = document.createElement("span");
      if (this.type == "String" && this.value.length > MAX_PLACEABLE_LENGTH) {
        // If the value is too long for localization, don't include the state.
        // Since the preferences system is designed to store short values, this
        // case happens very rarely, thus we keep the same DOM structure for
        // consistency even though we could avoid the extra "span" element.
        outerSpan.setAttribute("aria-label", this.value);
      } else {
        let spanL10nId = this.hasUserValue
          ? "about-config-pref-accessible-value-custom"
          : "about-config-pref-accessible-value-default";
        document.l10n.setAttributes(outerSpan, spanL10nId, {
          value: "" + this.value,
        });
      }
      outerSpan.appendChild(span);
      this.valueCell.textContent = "";
      this.valueCell.append(outerSpan);
      if (this.type == "Boolean") {
        document.l10n.setAttributes(
          this.editButton,
          "about-config-pref-toggle-button"
        );
        this.editButton.className = "button-toggle semi-transparent";
      } else {
        document.l10n.setAttributes(
          this.editButton,
          "about-config-pref-edit-button"
        );
        this.editButton.className = "button-edit semi-transparent";
      }
      this.editButton.removeAttribute("form");
      delete this.inputField;
    } else {
      this.valueCell.textContent = "";
      // The form is needed for the validation report to appear, but we need to
      // prevent the associated button from reloading the page.
      let form = document.createElement("form");
      form.addEventListener("submit", event => event.preventDefault());
      form.id = "form-edit";
      if (this.editing) {
        this.inputField = document.createElement("input");
        this.inputField.value = this.value;
        this.inputField.ariaLabel = this.name;
        if (this.type == "Number") {
          this.inputField.type = "number";
          this.inputField.required = true;
          this.inputField.min = -2147483648;
          this.inputField.max = 2147483647;
        } else {
          this.inputField.type = "text";
        }
        form.appendChild(this.inputField);
        document.l10n.setAttributes(
          this.editButton,
          "about-config-pref-save-button"
        );
        this.editButton.className = "primary button-save semi-transparent";
      } else {
        delete this.inputField;
        for (let type of ["Boolean", "Number", "String"]) {
          let radio = document.createElement("input");
          radio.type = "radio";
          radio.name = "type";
          radio.value = type;
          radio.checked = this.type == type;
          let radioSpan = document.createElement("span");
          document.l10n.setAttributes(radioSpan, STRINGS_ADD_BY_TYPE[type]);
          let radioLabel = document.createElement("label");
          radioLabel.append(radio, radioSpan);
          form.appendChild(radioLabel);
        }
        form.addEventListener("click", event => {
          if (event.target.name != "type") {
            return;
          }
          let type = event.target.value;
          if (this.type != type) {
            if (type == "Boolean") {
              this.value = true;
            } else if (type == "Number") {
              this.value = 0;
            } else {
              this.value = "";
            }
          }
        });
        document.l10n.setAttributes(
          this.editButton,
          "about-config-pref-add-button"
        );
        this.editButton.className = "button-add semi-transparent";
      }
      this.valueCell.appendChild(form);
      this.editButton.setAttribute("form", "form-edit");
    }
    this.editButton.disabled = this.isLocked;
    if (!this.isLocked && this.hasUserValue) {
      if (!this.resetButton) {
        this.resetButton = document.createElement("button");
        this.resetCell.appendChild(this.resetButton);
      }
      if (!this.hasDefaultValue) {
        document.l10n.setAttributes(
          this.resetButton,
          "about-config-pref-delete-button"
        );
        this.resetButton.className =
          "button-delete ghost-button semi-transparent";
      } else {
        document.l10n.setAttributes(
          this.resetButton,
          "about-config-pref-reset-button"
        );
        this.resetButton.className =
          "button-reset ghost-button semi-transparent";
      }
    } else if (this.resetButton) {
      this.resetButton.remove();
      delete this.resetButton;
    }

    this.refreshClass();
  }

  refreshClass() {
    if (!this._element) {
      // No need to update if this preference was never added to the table.
      return;
    }

    let className;
    if (this.hidden) {
      className = "hidden";
    } else {
      className =
        (this.hasUserValue ? "has-user-value " : "") +
        (this.isLocked ? "locked " : "") +
        (this.exists || this.isAddRow ? "" : "deleted ") +
        (this.isAddRow ? "add " : "") +
        (this.odd ? "odd " : "");
    }

    if (this._lastClassName !== className) {
      this._element.className = this._lastClassName = className;
    }
  }

  edit() {
    if (gPrefInEdit) {
      gPrefInEdit.endEdit();
    }
    gPrefInEdit = this;
    this.editing = true;
    this.refreshElement();
    // The type=number input isn't selected unless it's focused first.
    this.inputField.focus();
    this.inputField.select();
  }

  toggle() {
    this.#notifyWillChange();
    Services.prefs.setBoolPref(this.name, !this.value);
    this.#notifyChanged();
  }

  editOrToggle() {
    if (this.type == "Boolean") {
      this.toggle();
    } else {
      this.edit();
    }
  }

  save() {
    if (this.type == "Number" && !this.inputField.reportValidity()) {
      return;
    }

    this.#notifyWillChange();

    if (this.type == "Number") {
      Services.prefs.setIntPref(this.name, parseInt(this.inputField.value));
    } else {
      Services.prefs.setStringPref(this.name, this.inputField.value);
    }

    this.#notifyChanged();

    this.refreshValue();
    this.endEdit();
    this.editButton.focus();
  }

  endEdit() {
    this.editing = false;
    this.refreshElement();
    gPrefInEdit = null;
  }

  #notifyWillChange() {
    Services.obs.notifyObservers(
      null,
      "about-config-will-change-pref",
      this.name
    );
  }

  #notifyChanged() {
    Services.obs.notifyObservers(null, "about-config-changed-pref", this.name);
  }
}

let gPrefObserverRegistered = false;
let gPrefObserver = {
  observe(subject, topic, data) {
    let pref = gExistingPrefs.get(data) || gDeletedPrefs.get(data);
    if (pref) {
      pref.refreshValue();
      if (!pref.editing) {
        pref.refreshElement();
      }
      return;
    }

    let newPref = new PrefRow(data);
    if (newPref.matchesFilter) {
      document.getElementById("prefs").appendChild(newPref.getElement());
    }
  },
};

if (!Preferences.get("browser.aboutConfig.showWarning")) {
  // When showing the filtered preferences directly, remove the warning elements
  // immediately to prevent flickering, but wait to filter the preferences until
  // the value of the textbox has been restored from previous sessions.
  document.addEventListener("DOMContentLoaded", loadPrefs, { once: true });
  window.addEventListener(
    "load",
    () => {
      if (document.getElementById("about-config-search").value) {
        filterPrefs();
      }
    },
    { once: true }
  );
} else {
  document.addEventListener("DOMContentLoaded", function () {
    let warningButton = document.getElementById("warningButton");
    warningButton.addEventListener("click", onWarningButtonClick);
    warningButton.focus({ focusVisible: false });
  });
}

function onWarningButtonClick() {
  Services.prefs.setBoolPref(
    "browser.aboutConfig.showWarning",
    document.getElementById("showWarningNextTime").checked
  );
  loadPrefs();
}

function loadPrefs() {
  [...document.styleSheets].find(s => s.title == "infop").disabled = true;

  let { content } = document.getElementById("main");
  document.body.textContent = "";
  document.body.appendChild(content);

  let search = (gSearchInput = document.getElementById("about-config-search"));
  let prefs = (gPrefsTable = document.getElementById("prefs"));
  let showAll = document.getElementById("show-all");
  gShowOnlyModifiedCheckbox = document.getElementById(
    "about-config-show-only-modified"
  );
  search.focus();
  gShowOnlyModifiedCheckbox.checked = false;

  for (let name of Services.prefs.getChildList("")) {
    new PrefRow(name);
  }

  search.addEventListener("keypress", event => {
    if (event.key == "Escape") {
      // The ESC key returns immediately to the initial empty page.
      search.value = "";
      gFilterPrefsTask.disarm();
      filterPrefs();
    } else if (event.key == "Enter") {
      // The Enter key filters immediately even if the search string is short.
      gFilterPrefsTask.disarm();
      filterPrefs({ shortString: true });
    }
  });

  search.addEventListener("input", () => {
    // We call "disarm" to restart the timer at every input.
    gFilterPrefsTask.disarm();
    if (search.value.trim().length < SEARCH_AUTO_MIN_CHARACTERS) {
      // Return immediately to the empty page if the search string is short.
      filterPrefs();
    } else {
      gFilterPrefsTask.arm();
    }
  });

  gShowOnlyModifiedCheckbox.addEventListener("change", () => {
    // This checkbox:
    // - Filters results to only modified prefs when search query is entered
    // - Shows all modified prefs, in show all mode, and after initial checkbox click
    let tableHidden = !document.body.classList.contains("table-shown");
    filterPrefs({
      showAll:
        gFilterShowAll || (gShowOnlyModifiedCheckbox.checked && tableHidden),
    });
  });

  showAll.addEventListener("click", () => {
    search.focus();
    search.value = "";
    gFilterPrefsTask.disarm();
    filterPrefs({ showAll: true });
  });

  function shouldBeginEdit(event) {
    if (
      event.target.localName != "button" &&
      event.target.localName != "input"
    ) {
      let row = event.target.closest("tr");
      return row && row._pref.exists && !row._pref.isLocked;
    }
    return false;
  }

  // Disable double/triple-click text selection since that triggers edit/toggle.
  prefs.addEventListener("mousedown", event => {
    if (event.detail > 1 && shouldBeginEdit(event)) {
      event.preventDefault();
    }
  });

  prefs.addEventListener("click", event => {
    if (event.detail == 2 && shouldBeginEdit(event)) {
      event.target.closest("tr")._pref.editOrToggle();
      return;
    }

    if (event.target.localName != "button") {
      return;
    }

    let pref = event.target.closest("tr")._pref;
    let button = event.target.closest("button");

    if (button.classList.contains("button-add")) {
      pref.isAddRow = false;
      Preferences.set(pref.name, pref.value);
      if (pref.type == "Boolean") {
        pref.refreshClass();
      } else {
        pref.edit();
      }
    } else if (
      button.classList.contains("button-toggle") ||
      button.classList.contains("button-edit")
    ) {
      pref.editOrToggle();
    } else if (button.classList.contains("button-save")) {
      pref.save();
    } else {
      // This is "button-reset" or "button-delete".
      pref.editing = false;
      Services.prefs.clearUserPref(pref.name);
      pref.editButton.focus();
    }
  });

  window.addEventListener("keypress", event => {
    if (event.target != search && event.key == "Escape" && gPrefInEdit) {
      gPrefInEdit.endEdit();
    }
  });
}

function filterPrefs(options = {}) {
  if (gPrefInEdit) {
    gPrefInEdit.endEdit();
  }
  gDeletedPrefs.clear();

  let searchName = gSearchInput.value.trim();
  if (searchName.length < SEARCH_AUTO_MIN_CHARACTERS && !options.shortString) {
    searchName = "";
  }

  gFilterString = searchName.toLowerCase();
  gFilterShowAll = !!options.showAll;

  gFilterPattern = null;
  if (gFilterString.includes("*")) {
    gFilterPattern = new RegExp(gFilterString.replace(/\*+/g, ".*"), "i");
    gFilterString = "";
  }

  let showResults = gFilterString || gFilterPattern || gFilterShowAll;
  document.body.classList.toggle("table-shown", showResults);

  let prefArray = [];
  if (showResults) {
    if (!gSortedExistingPrefs) {
      gSortedExistingPrefs = [...gExistingPrefs.values()];
      gSortedExistingPrefs.sort((a, b) => a.name > b.name);
    }
    prefArray = gSortedExistingPrefs;
  }

  // The slowest operations tend to be the addition and removal of DOM nodes, so
  // this algorithm tries to reduce removals by hiding nodes instead. This
  // happens frequently when the set narrows while typing preference names. We
  // iterate the nodes already in the table in parallel to those we want to
  // show, because the two lists are sorted and they will often match already.
  let fragment = null;
  let indexInArray = 0;
  let elementInTable = gPrefsTable.firstElementChild;
  let odd = false;
  let hasVisiblePrefs = false;
  while (indexInArray < prefArray.length || elementInTable) {
    // For efficiency, filter the array while we are iterating.
    let prefInArray = prefArray[indexInArray];
    if (prefInArray) {
      if (!prefInArray.matchesFilter) {
        indexInArray++;
        continue;
      }
      prefInArray.hidden = false;
      prefInArray.odd = odd;
    }

    let prefInTable = elementInTable && elementInTable._pref;
    if (!prefInTable) {
      // We're at the end of the table, we just have to insert all the matching
      // elements that remain in the array. We can use a fragment to make the
      // insertions faster, which is useful during the initial filtering.
      if (!fragment) {
        fragment = document.createDocumentFragment();
      }
      fragment.appendChild(prefInArray.getElement());
    } else if (prefInTable == prefInArray) {
      // We got two matching elements, we just need to update the visibility.
      elementInTable = elementInTable.nextElementSibling;
    } else if (prefInArray && prefInArray.name < prefInTable.name) {
      // The iteration in the table is ahead of the iteration in the array.
      // Insert or move the array element, and advance the array index.
      gPrefsTable.insertBefore(prefInArray.getElement(), elementInTable);
    } else {
      // The iteration in the array is ahead of the iteration in the table.
      // Hide the element in the table, and advance to the next element.
      let nextElementInTable = elementInTable.nextElementSibling;
      if (!prefInTable.exists) {
        // Remove rows for deleted preferences, or temporary addition rows.
        elementInTable.remove();
      } else {
        // Keep the element for the next filtering if the preference exists.
        prefInTable.hidden = true;
        prefInTable.refreshClass();
      }
      elementInTable = nextElementInTable;
      continue;
    }

    prefInArray.refreshClass();
    odd = !odd;
    indexInArray++;
    hasVisiblePrefs = true;
  }

  if (fragment) {
    gPrefsTable.appendChild(fragment);
  }

  gPrefsTable.toggleAttribute("has-visible-prefs", hasVisiblePrefs);

  if (searchName && !gExistingPrefs.has(searchName)) {
    let addPrefRow = new PrefRow(searchName, { isAddRow: true });
    addPrefRow.odd = odd;
    gPrefsTable.appendChild(addPrefRow.getElement());
  }

  // We only start observing preference changes after the first search is done,
  // so that newly added preferences won't appear while the page is still empty.
  if (!gPrefObserverRegistered) {
    gPrefObserverRegistered = true;
    Services.prefs.addObserver("", gPrefObserver);
    window.addEventListener(
      "unload",
      () => {
        Services.prefs.removeObserver("", gPrefObserver);
      },
      { once: true }
    );
  }
}
