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

/**
 * This module provides the means to monitor and query for tab collections against open
 * browser windows and allow listeners to be notified of changes to those collections.
 */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
  EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

const TAB_ATTRS_TO_WATCH = Object.freeze([
  "attention",
  "image",
  "label",
  "muted",
  "soundplaying",
  "titlechanged",
]);
const TAB_CHANGE_EVENTS = Object.freeze([
  "TabAttrModified",
  "TabClose",
  "TabMove",
  "TabOpen",
  "TabPinned",
  "TabUnpinned",
  "SplitViewCreated",
  "SplitViewRemoved",
]);
const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([
  "activate",
  "sizemodechange",
  "TabAttrModified",
  "TabClose",
  "TabOpen",
  "TabPinned",
  "TabUnpinned",
  "TabSelect",
  "TabAttrModified",
]);

// Debounce tab/tab recency changes and dispatch max once per frame at 60fps
const CHANGES_DEBOUNCE_MS = 1000 / 60;

/**
 * A sort function used to order tabs by most-recently seen and active.
 */
export function lastSeenActiveSort(a, b) {
  let dt = b.lastSeenActive - a.lastSeenActive;
  if (dt) {
    return dt;
  }
  // try to break a deadlock by sorting the selected tab higher
  if (!(a.selected || b.selected)) {
    return 0;
  }
  return a.selected ? -1 : 1;
}

/**
 * Provides a object capable of monitoring and accessing tab collections for either
 * private or non-private browser windows. As the class extends EventTarget, consumers
 * should add event listeners for the change events.
 *
 * @param {boolean} options.usePrivateWindows
              Constrain to only windows that match this privateness. Defaults to false.
 * @param {Window | null} options.exclusiveWindow
 *            Constrain to only a specific window.
 */
class OpenTabsTarget extends EventTarget {
  #changedWindowsByType = {
    TabChange: new Set(),
    TabRecencyChange: new Set(),
  };
  #sourceEventsByType = {
    TabChange: new Set(),
    TabRecencyChange: new Set(),
  };
  #dispatchChangesTask;
  #started = false;
  #watchedWindows = new Set();

  #exclusiveWindowWeakRef = null;
  usePrivateWindows = false;

  constructor(options = {}) {
    super();
    this.usePrivateWindows = !!options.usePrivateWindows;

    if (options.exclusiveWindow) {
      this.exclusiveWindow = options.exclusiveWindow;
      this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`;
    } else {
      this.everyWindowCallbackId = `opentabs-${
        this.usePrivateWindows ? "private" : "non-private"
      }`;
    }
  }

  get exclusiveWindow() {
    return this.#exclusiveWindowWeakRef?.get();
  }
  set exclusiveWindow(newValue) {
    if (newValue) {
      this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue);
    } else {
      this.#exclusiveWindowWeakRef = null;
    }
  }

  includeWindowFilter(win) {
    if (this.#exclusiveWindowWeakRef) {
      return win == this.exclusiveWindow;
    }
    return (
      win.gBrowser &&
      !win.closed &&
      this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
    );
  }

  get currentWindows() {
    return lazy.EveryWindow.readyWindows.filter(win =>
      this.includeWindowFilter(win)
    );
  }

  /**
   * A promise that resolves to all matched windows once their delayedStartupPromise resolves
   */
  get readyWindowsPromise() {
    let windowList = Array.from(
      Services.wm.getEnumerator("navigator:browser")
    ).filter(win => {
      // avoid waiting for windows we definitely don't care about
      if (this.#exclusiveWindowWeakRef) {
        return this.exclusiveWindow == win;
      }
      return (
        this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win)
      );
    });
    return Promise.allSettled(
      windowList.map(win => win.delayedStartupPromise)
    ).then(() => {
      // re-filter the list as properties might have changed in the interim
      return windowList.filter(() => this.includeWindowFilter);
    });
  }

  haveListenersForEvent(eventType) {
    switch (eventType) {
      case "TabChange":
        return Services.els.hasListenersFor(this, "TabChange");
      case "TabRecencyChange":
        return Services.els.hasListenersFor(this, "TabRecencyChange");
      default:
        return false;
    }
  }

  get haveAnyListeners() {
    return (
      this.haveListenersForEvent("TabChange") ||
      this.haveListenersForEvent("TabRecencyChange")
    );
  }

  /**
   * @param {string} type
   *        Either "TabChange" or "TabRecencyChange"
   * @param {object | Function} listener
   * @param {object} [options]
   */
  addEventListener(type, listener, options) {
    let hadListeners = this.haveAnyListeners;
    super.addEventListener(type, listener, options);

    // if this is the first listener, start up all the window & tab monitoring
    if (!hadListeners && this.haveAnyListeners) {
      this.start();
    }
  }

  /**
   * @param {string} type
   *        Either "TabChange" or "TabRecencyChange"
   * @param {object | Function} listener
   */
  removeEventListener(type, listener) {
    let hadListeners = this.haveAnyListeners;
    super.removeEventListener(type, listener);

    // if this was the last listener, we can stop all the window & tab monitoring
    if (hadListeners && !this.haveAnyListeners) {
      this.stop();
    }
  }

  /**
   * Begin watching for tab-related events from all browser windows matching the instance's private property
   */
  start() {
    if (this.#started) {
      return;
    }
    // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves.
    lazy.EveryWindow.registerCallback(
      this.everyWindowCallbackId,
      win => this.#watchWindow(win),
      win => this.#unwatchWindow(win)
    );
    this.#started = true;
  }

  /**
   * Stop watching for tab-related events from all browser windows and clean up.
   */
  stop() {
    if (this.#started) {
      lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId);
      this.#started = false;
    }
    for (let changedWindows of Object.values(this.#changedWindowsByType)) {
      changedWindows.clear();
    }
    for (let sourceEvents of Object.values(this.#sourceEventsByType)) {
      sourceEvents.clear();
    }
    this.#watchedWindows.clear();
    this.#dispatchChangesTask?.disarm();
  }

  /**
   * Add listeners for tab-related events from the given window. The consumer's
   * listeners will always be notified at least once for newly-watched window.
   */
  #watchWindow(win) {
    if (!this.includeWindowFilter(win)) {
      return;
    }
    this.#watchedWindows.add(win);
    const { tabContainer } = win.gBrowser;
    tabContainer.addEventListener("TabAttrModified", this);
    tabContainer.addEventListener("TabClose", this);
    tabContainer.addEventListener("TabMove", this);
    tabContainer.addEventListener("TabOpen", this);
    tabContainer.addEventListener("TabPinned", this);
    tabContainer.addEventListener("TabUnpinned", this);
    tabContainer.addEventListener("TabSelect", this);
    tabContainer.addEventListener("SplitViewCreated", this);
    tabContainer.addEventListener("SplitViewRemoved", this);
    win.addEventListener("activate", this);
    win.addEventListener("sizemodechange", this);

    this.#scheduleEventDispatch("TabChange", {
      sourceWindowId: win.windowGlobalChild.innerWindowId,
      sourceEvent: "watchWindow",
    });
    this.#scheduleEventDispatch("TabRecencyChange", {
      sourceWindowId: win.windowGlobalChild.innerWindowId,
      sourceEvent: "watchWindow",
    });
  }

  /**
   * Remove all listeners for tab-related events from the given window.
   * Consumers will always be notified at least once for unwatched window.
   */
  #unwatchWindow(win) {
    // We check the window is in our watchedWindows collection rather than currentWindows
    // as the unwatched window may not match the criteria we used to watch it anymore,
    // and we need to unhook our event listeners regardless.
    if (this.#watchedWindows.has(win)) {
      this.#watchedWindows.delete(win);

      const { tabContainer } = win.gBrowser;
      tabContainer.removeEventListener("TabAttrModified", this);
      tabContainer.removeEventListener("TabClose", this);
      tabContainer.removeEventListener("TabMove", this);
      tabContainer.removeEventListener("TabOpen", this);
      tabContainer.removeEventListener("TabPinned", this);
      tabContainer.removeEventListener("TabSelect", this);
      tabContainer.removeEventListener("TabUnpinned", this);
      tabContainer.removeEventListener("SplitViewCreated", this);
      tabContainer.removeEventListener("SplitViewRemoved", this);
      win.removeEventListener("activate", this);
      win.removeEventListener("sizemodechange", this);

      this.#scheduleEventDispatch("TabChange", {
        sourceWindowId: win.windowGlobalChild.innerWindowId,
        sourceEvent: "unwatchWindow",
      });
      this.#scheduleEventDispatch("TabRecencyChange", {
        sourceWindowId: win.windowGlobalChild.innerWindowId,
        sourceEvent: "unwatchWindow",
      });
    }
  }

  /**
   * Flag the need to notify all our consumers of a change to open tabs.
   * Repeated calls within approx 16ms will be consolidated
   * into one event dispatch.
   */
  #scheduleEventDispatch(eventType, { sourceWindowId, sourceEvent } = {}) {
    if (!this.haveListenersForEvent(eventType)) {
      return;
    }

    this.#sourceEventsByType[eventType].add(sourceEvent);
    this.#changedWindowsByType[eventType].add(sourceWindowId);
    // Queue up an event dispatch - we use a deferred task to make this less noisy by
    // consolidating multiple change events into one.
    if (!this.#dispatchChangesTask) {
      this.#dispatchChangesTask = new lazy.DeferredTask(() => {
        this.#dispatchChanges();
      }, CHANGES_DEBOUNCE_MS);
    }
    this.#dispatchChangesTask.arm();
  }

  #dispatchChanges() {
    this.#dispatchChangesTask?.disarm();
    for (let [eventType, changedWindowIds] of Object.entries(
      this.#changedWindowsByType
    )) {
      let sourceEvents = this.#sourceEventsByType[eventType];
      if (this.haveListenersForEvent(eventType) && changedWindowIds.size) {
        let changeEvent = new CustomEvent(eventType, {
          detail: {
            windowIds: [...changedWindowIds],
            sourceEvents: [...sourceEvents],
          },
        });
        this.dispatchEvent(changeEvent);
        changedWindowIds.clear();
      }
      sourceEvents?.clear();
    }
  }

  /**
   * @param {Window} win
   * @param {boolean} sortByRecency
   * @returns {Array<Tab>}
   *    The list of visible tabs for the browser window
   */
  getTabsForWindow(win, sortByRecency = false) {
    if (this.currentWindows.includes(win)) {
      const tabs = win.gBrowser.openTabs.filter(tab => !tab.hidden);
      return sortByRecency ? tabs.toSorted(lastSeenActiveSort) : tabs;
    }
    return [];
  }

  /**
   * Get an aggregated list of tabs from all the same-privateness browser windows.
   *
   * @returns {MozTabbrowserTab[]}
   */
  getAllTabs() {
    return this.currentWindows.flatMap(win => this.getTabsForWindow(win));
  }

  /**
   * @returns {Array<Tab>}
   *    A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows.
   */
  getRecentTabs() {
    return this.getAllTabs().sort(lastSeenActiveSort);
  }

  handleEvent({ detail, target, type }) {
    const win = target.ownerGlobal;
    // NOTE: we already filtered on privateness by not listening for those events
    // from private/not-private windows
    if (
      type == "TabAttrModified" &&
      !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr))
    ) {
      return;
    }

    if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) {
      this.#scheduleEventDispatch("TabRecencyChange", {
        sourceWindowId: win.windowGlobalChild.innerWindowId,
        sourceEvent: type,
      });
    }
    if (TAB_CHANGE_EVENTS.includes(type)) {
      this.#scheduleEventDispatch("TabChange", {
        sourceWindowId: win.windowGlobalChild.innerWindowId,
        sourceEvent: type,
      });
    }
  }
}

const gExclusiveWindows = new (class {
  perWindowInstances = new WeakMap();
  constructor() {
    Services.obs.addObserver(this, "domwindowclosed");
  }
  observe(subject) {
    let win = subject;
    let winTarget = this.perWindowInstances.get(win);
    if (winTarget) {
      winTarget.stop();
      this.perWindowInstances.delete(win);
    }
  }
})();

/**
 * Get an OpenTabsTarget instance constrained to a specific window.
 *
 * @param {Window} exclusiveWindow
 * @returns {OpenTabsTarget}
 */
const getTabsTargetForWindow = function (exclusiveWindow) {
  let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow);
  if (instance) {
    return instance;
  }
  instance = new OpenTabsTarget({
    exclusiveWindow,
  });
  gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance);
  return instance;
};

const NonPrivateTabs = new OpenTabsTarget({
  usePrivateWindows: false,
});

const PrivateTabs = new OpenTabsTarget({
  usePrivateWindows: true,
});

export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow };
