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

import { AsyncSetting } from "chrome://global/content/preferences/AsyncSetting.mjs";
import { Preference } from "chrome://global/content/preferences/Preference.mjs";
import { Setting } from "chrome://global/content/preferences/Setting.mjs";

/** @import {PreferenceConfig} from "chrome://global/content/preferences/Preference.mjs" */
/** @import {SettingConfig} from "chrome://global/content/preferences/Setting.mjs" */
/** @import {DeferredTask} from "resource://gre/modules/DeferredTask.sys.mjs" */

/**
 * @typedef {{ _deferredValueUpdateTask: DeferredTask }} DeferredValueObject
 * @typedef {DeferredValueObject & HTMLElement} DeferredValueHTMLElement
 */

/** @type {{ DeferredTask: typeof DeferredTask }} */
// @ts-expect-error bug 1996860
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
});

const domContentLoadedPromise = new Promise(resolve => {
  window.addEventListener("DOMContentLoaded", resolve, {
    capture: true,
    once: true,
  });
});

export const Preferences = {
  /**
   * @type {Record<string, Preference>}
   */
  _all: {},

  /**
   * @type {Map<string, Setting>}
   */
  _settings: new Map(),

  /**
   * @param {PreferenceConfig} prefInfo
   */
  _add(prefInfo) {
    if (this._all[prefInfo.id]) {
      throw new Error(`preference with id '${prefInfo.id}' already added`);
    }
    const pref = new Preference(prefInfo);
    this._all[pref.id] = pref;
    domContentLoadedPromise.then(() => {
      if (!this.updateQueued) {
        pref.updateElements();
      }
    });
    return pref;
  },

  /**
   * @param {PreferenceConfig} prefInfo
   * @returns {Preference}
   */
  add(prefInfo) {
    const pref = this._add(prefInfo);
    return pref;
  },

  /**
   * @param {Array<PreferenceConfig>} prefInfos
   */
  addAll(prefInfos) {
    prefInfos.map(prefInfo => this._add(prefInfo));
  },

  /**
   * @param {string} id
   * @returns {Preference | null}
   */
  get(id) {
    return this._all[id] || null;
  },

  /**
   * @returns {Array<Preference>}
   */
  getAll() {
    return Object.values(this._all);
  },

  /**
   * A configuration object that adds an element (control) associated with a pref,
   * that includes all of the configuration for the control
   * such as its Fluent strings, support page, subcategory etc.
   *
   * @param {SettingConfig} settingConfig
   */
  addSetting(settingConfig) {
    this._settings.set(
      settingConfig.id,
      new Setting(settingConfig.id, settingConfig)
    );
  },

  /**
   * @param {string} settingId
   * @returns {Setting | undefined}
   */
  getSetting(settingId) {
    return this._settings.get(settingId);
  },

  defaultBranch: Services.prefs.getDefaultBranch(""),

  get type() {
    return document.documentElement.getAttribute("type") || "";
  },

  get instantApply() {
    // The about:preferences page forces instantApply.
    // TODO: Remove forceEnableInstantApply in favor of always applying in a
    // parent and never applying in a child (bug 1775386).
    if (this._instantApplyForceEnabled) {
      return true;
    }

    // Dialogs of type="child" are never instantApply.
    return this.type !== "child";
  },

  _instantApplyForceEnabled: false,

  // Override the computed value of instantApply for this window.
  forceEnableInstantApply() {
    this._instantApplyForceEnabled = true;
  },

  /**
   * @param {nsISupports} _
   * @param {string} __
   * @param {string} data
   */
  observe(_, __, data) {
    const pref = this._all[data];
    if (pref) {
      pref.value = pref.valueFromPreferences;
    }
  },

  updateQueued: false,

  queueUpdateOfAllElements() {
    if (this.updateQueued) {
      return;
    }

    this.updateQueued = true;

    Services.tm.dispatchToMainThread(() => {
      let startTime = ChromeUtils.now();

      const elements = document.querySelectorAll("[preference]");
      for (const element of elements) {
        const id = element.getAttribute("preference");
        let preference = this.get(id);
        if (!preference) {
          console.error(`Missing preference for ID ${id}`);
          continue;
        }

        preference.setElementValue(element);
      }

      ChromeUtils.addProfilerMarker(
        "Preferences",
        { startTime },
        `updateAllElements: ${elements.length} preferences updated`
      );

      this.updateQueued = false;
    });
  },

  /**
   * @param {Event} _
   */
  onUnload(_) {
    this._settings.forEach(setting => setting?.destroy?.());
    Services.prefs.removeObserver("", /** @type {nsIObserver} */ (this));
  },

  QueryInterface: ChromeUtils.generateQI(["nsITimerCallback", "nsIObserver"]),

  _deferredValueUpdateElements: new Set(),

  /**
   * @param {boolean} aFlushToDisk
   */
  writePreferences(aFlushToDisk) {
    // Write all values to preferences.
    if (this._deferredValueUpdateElements.size) {
      this._finalizeDeferredElements();
    }

    const preferences = Preferences.getAll();
    for (const preference of preferences) {
      preference.batching = true;
      preference.valueFromPreferences = preference.value;
      preference.batching = false;
    }
    if (aFlushToDisk) {
      Services.prefs.savePrefFile(null);
    }
  },

  /**
   * @param {HTMLElement} aStartElement
   */
  getPreferenceElement(aStartElement) {
    let temp = aStartElement;
    while (
      temp &&
      temp.nodeType == Node.ELEMENT_NODE &&
      !temp.hasAttribute("preference")
    ) {
      // @ts-expect-error
      temp = temp.parentNode;
    }
    return temp && temp.nodeType == Node.ELEMENT_NODE ? temp : aStartElement;
  },

  /**
   * @param {DeferredValueHTMLElement} aElement
   */
  _deferredValueUpdate(aElement) {
    delete aElement._deferredValueUpdateTask;
    const prefID = aElement.getAttribute("preference");
    const preference = Preferences.get(prefID);
    const prefVal = preference.getElementValue(aElement);
    preference.value = prefVal;
    this._deferredValueUpdateElements.delete(aElement);
  },

  _finalizeDeferredElements() {
    for (const el of this._deferredValueUpdateElements) {
      if (el._deferredValueUpdateTask) {
        el._deferredValueUpdateTask.finalize();
      }
    }
  },

  /**
   * @param {HTMLElement} aElement
   */
  userChangedValue(aElement) {
    const element = /** @type {DeferredValueHTMLElement} */ (
      this.getPreferenceElement(aElement)
    );
    if (element.hasAttribute("preference")) {
      if (element.getAttribute("delayprefsave") != "true") {
        const preference = Preferences.get(element.getAttribute("preference"));
        const prefVal = preference.getElementValue(element);
        preference.value = prefVal;
      } else {
        if (!element._deferredValueUpdateTask) {
          element._deferredValueUpdateTask = new lazy.DeferredTask(
            this._deferredValueUpdate.bind(this, element),
            1000
          );
          this._deferredValueUpdateElements.add(element);
        } else {
          // Each time the preference is changed, restart the delay.
          element._deferredValueUpdateTask.disarm();
        }
        element._deferredValueUpdateTask.arm();
      }
    }
  },

  /**
   * @typedef {{ sourceEvent: CommandEventWithSource } & CommandEvent} CommandEventWithSource
   * @param {CommandEventWithSource} event
   */
  onCommand(event) {
    // This "command" event handler tracks changes made to preferences by
    // the user in this window.
    if (event.sourceEvent) {
      event = event.sourceEvent;
    }
    this.userChangedValue(/** @type {HTMLElement} */ (event.target));
  },

  /** @param {Event} event */
  onChange(event) {
    // This "change" event handler tracks changes made to preferences by
    // the user in this window.
    this.userChangedValue(/** @type {HTMLElement} */ (event.target));
  },

  /** @param {Event} event */
  onInput(event) {
    // This "input" event handler tracks changes made to preferences by
    // the user in this window.
    this.userChangedValue(/** @type {HTMLElement} */ (event.target));
  },

  /**
   * @param {string} aEventName
   * @param {HTMLElement} aTarget
   */
  _fireEvent(aEventName, aTarget) {
    try {
      const event = new CustomEvent(aEventName, {
        bubbles: true,
        cancelable: true,
      });
      return aTarget.dispatchEvent(event);
    } catch (e) {
      console.error(e);
    }
    return false;
  },

  /**
   * @param {Event} event
   */
  onDialogAccept(event) {
    let dialog = document.querySelector("dialog");
    if (!this._fireEvent("beforeaccept", dialog)) {
      event.preventDefault();
      return false;
    }
    this.writePreferences(true);
    return true;
  },

  /**
   * @param {Event} event
   */
  close(event) {
    if (Preferences.instantApply) {
      window.close();
    }
    event.stopPropagation();
    event.preventDefault();
  },

  /**
   * @param {Event} event
   */
  handleEvent(event) {
    switch (event.type) {
      case "toggle":
      case "change":
        return this.onChange(event);
      case "command":
        return this.onCommand(/** @type {CommandEventWithSource} */ (event));
      case "dialogaccept":
        return this.onDialogAccept(event);
      case "input":
        return this.onInput(event);
      case "unload":
        return this.onUnload(event);
      default:
        return undefined;
    }
  },

  /** @type {WeakMap<Element, (el: Element) => any>} */
  _syncFromPrefListeners: new WeakMap(),
  /** @type {WeakMap<Element, (el: Element) => any>} */
  _syncToPrefListeners: new WeakMap(),

  /**
   * @param {Element} aElement
   * @param {(el: Element) => any} callback
   */
  addSyncFromPrefListener(aElement, callback) {
    this._syncFromPrefListeners.set(aElement, callback);
    if (this.updateQueued) {
      return;
    }
    // Make sure elements are updated correctly with the listener attached.
    let elementPref = aElement.getAttribute("preference");
    if (elementPref) {
      let pref = this.get(elementPref);
      if (pref) {
        pref.updateElements();
      }
    }
  },

  /**
   * @param {Element} aElement
   * @param {(el: Element) => any} callback
   */
  addSyncToPrefListener(aElement, callback) {
    this._syncToPrefListeners.set(aElement, callback);
    if (this.updateQueued) {
      return;
    }
    // Make sure elements are updated correctly with the listener attached.
    let elementPref = aElement.getAttribute("preference");
    if (elementPref) {
      let pref = this.get(elementPref);
      if (pref) {
        pref.updateElements();
      }
    }
  },

  /**
   * @param {Element} aElement
   */
  removeSyncFromPrefListener(aElement) {
    this._syncFromPrefListeners.delete(aElement);
  },

  /**
   * @param {Element} aElement
   */
  removeSyncToPrefListener(aElement) {
    this._syncToPrefListeners.delete(aElement);
  },

  AsyncSetting,
  Preference,
  Setting,
};

Services.prefs.addObserver("", /** @type {nsIObserver} */ (Preferences));
window.addEventListener("toggle", Preferences);
window.addEventListener("change", Preferences);
window.addEventListener("command", Preferences);
window.addEventListener("dialogaccept", Preferences);
window.addEventListener("input", Preferences);
window.addEventListener("select", Preferences);
window.addEventListener("unload", Preferences, { once: true });
