/* 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 tracks each browser window and informs network module
 * the current selected tab's content outer window ID.
 */

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

// Lazy getters

XPCOMUtils.defineLazyServiceGetters(lazy, {
  BrowserHandler: ["@mozilla.org/browser/clh;1", Ci.nsIBrowserHandler],
});

ChromeUtils.defineESModuleGetters(lazy, {
  AIWindow:
    "moz-src:///browser/components/aiwindow/ui/modules/AIWindow.sys.mjs",
  HomePage: "resource:///modules/HomePage.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gPreferWindowsOnCurrentVirtualDesktop",
  "widget.prefer_windows_on_current_virtual_desktop"
);

// Constants
const TAB_EVENTS = ["TabBrowserInserted", "TabSelect"];
const WINDOW_EVENTS = ["activate", "unload"];
const DEBUG = false;

// Variables
let _lastCurrentBrowserId = 0;
let _trackedWindows = [];

// Global methods
function debug(s) {
  if (DEBUG) {
    dump("-*- UpdateBrowserIDHelper: " + s + "\n");
  }
}

function _updateCurrentBrowserId(browser) {
  // _trackedWindows[0] may be a minimized window because `activate` can fire
  // while the window is still in STATE_MINIMIZED on macOS (see bug 2007691),
  // so compare against the topmost non-minimized tracked window instead.
  const topNonMinimized = _trackedWindows.find(
    w => !w.closed && w.windowState != w.STATE_MINIMIZED
  );
  if (
    !browser.browserId ||
    browser.browserId === _lastCurrentBrowserId ||
    browser.documentGlobal != topNonMinimized
  ) {
    return;
  }

  // Guard on DEBUG here because materializing a long data URI into
  // a JS string for concatenation is not free.
  if (DEBUG) {
    debug(
      `Current window uri=${browser.currentURI?.spec} browser id=${browser.browserId}`
    );
  }

  _lastCurrentBrowserId = browser.browserId;
  let idWrapper = Cc["@mozilla.org/supports-PRUint64;1"].createInstance(
    Ci.nsISupportsPRUint64
  );
  idWrapper.data = _lastCurrentBrowserId;
  Services.obs.notifyObservers(idWrapper, "net:current-browser-id");
}

function _handleEvent(event) {
  switch (event.type) {
    case "TabBrowserInserted":
      if (
        event.target.documentGlobal.gBrowser.selectedBrowser ===
        event.target.linkedBrowser
      ) {
        _updateCurrentBrowserId(event.target.linkedBrowser);
      }
      break;
    case "TabSelect":
      _updateCurrentBrowserId(event.target.linkedBrowser);
      break;
    case "activate":
      WindowHelper.onActivate(event.target);
      break;
    case "unload":
      WindowHelper.removeWindow(event.currentTarget);
      break;
  }
}

// Tracks the window at the front of the list. Minimized state is intentionally
// not considered here. Callers that need to skip minimized windows should do so
// at read time. See bug 2007691: on macOS, `activate` fires before the window
// state transitions out of STATE_MINIMIZED, so any write-time filter on the
// minimized state would place the just-activated window in the wrong slot.
function _trackWindowOrder(window) {
  _trackedWindows.unshift(window);
}

function _untrackWindowOrder(window) {
  let idx = _trackedWindows.indexOf(window);
  if (idx >= 0) {
    _trackedWindows.splice(idx, 1);
  }
}

function topicObserved(observeTopic, checkFn) {
  return new Promise((resolve, reject) => {
    function observer(subject, topic, data) {
      try {
        if (checkFn && !checkFn(subject, data)) {
          return;
        }
        Services.obs.removeObserver(observer, topic);
        checkFn = null;
        resolve([subject, data]);
      } catch (ex) {
        Services.obs.removeObserver(observer, topic);
        checkFn = null;
        reject(ex);
      }
    }
    Services.obs.addObserver(observer, observeTopic);
  });
}

// Methods that impact a window. Put into single object for organization.
var WindowHelper = {
  addWindow(window) {
    // Add event listeners
    TAB_EVENTS.forEach(function (event) {
      window.gBrowser.tabContainer.addEventListener(event, _handleEvent);
    });
    WINDOW_EVENTS.forEach(function (event) {
      window.addEventListener(event, _handleEvent);
    });

    _trackWindowOrder(window);

    // Update the selected tab's content outer window ID.
    _updateCurrentBrowserId(window.gBrowser.selectedBrowser);
  },

  removeWindow(window) {
    _untrackWindowOrder(window);

    // Remove the event listeners
    TAB_EVENTS.forEach(function (event) {
      window.gBrowser.tabContainer.removeEventListener(event, _handleEvent);
    });
    WINDOW_EVENTS.forEach(function (event) {
      window.removeEventListener(event, _handleEvent);
    });
  },

  onActivate(window) {
    // If this window was the last focused window, we don't need to do anything
    if (window == _trackedWindows[0]) {
      return;
    }

    _untrackWindowOrder(window);
    _trackWindowOrder(window);

    _updateCurrentBrowserId(window.gBrowser.selectedBrowser);
  },
};

export const BrowserWindowTracker = {
  pendingWindows: new Map(),

  /**
   * Get the most recent browser window.
   * Note that with the default options this may return null on Windows if
   * there are no open windows in the current virtual desktop. To prevent this,
   * set `options.allowFromInactiveWorkspace` to true.
   *
   * @param {object} options - An object accepting the arguments for the search.
   * @param {boolean} [options.private]
   *   true to only search for private windows.
   *   false to restrict the search to non-private windows.
   *   If the property is not provided, search for either. If permanent private
   *   browsing is enabled this option will be ignored!
   * @param {boolean} [options.allowPopups]: true if popup windows are
   *   permitted.
   * @param {boolean} [options.allowTaskbarTabs] true if taskbar tab windows
   *  are permitted.
   * @param {boolean} [options.allowFromInactiveWorkspace] true if window is allowed to
   *  be from a different virtual desktop (what Windows calls workspaces).
   *  Only has an effect on Windows.
   *
   * @returns {Window | null} The current top/selected window.
   *  Can return null on MacOS when there is no open window.
   */
  getTopWindow(options = {}) {
    let cloakedWin = null;
    let minimizedWin = null;
    for (let win of _trackedWindows) {
      if (
        !win.closed &&
        (options.allowPopups || win.toolbar.visible) &&
        (options.allowTaskbarTabs ||
          !win.document.documentElement.hasAttribute("taskbartab")) &&
        (!("private" in options) ||
          lazy.PrivateBrowsingUtils.permanentPrivateBrowsing ||
          lazy.PrivateBrowsingUtils.isWindowPrivate(win) == options.private)
      ) {
        // On Windows, windows on a different virtual desktop (what Windows calls
        // workspaces) are cloaked.
        if (win.isCloaked && lazy.gPreferWindowsOnCurrentVirtualDesktop) {
          // Even if we allow from an inactive workspace, prefer windows that
          // are not cloaked, so that we don't switch workspaces unnecessarily.
          if (!cloakedWin && options.allowFromInactiveWorkspace) {
            cloakedWin = win;
          }
          continue;
        }
        // Prefer non-minimized windows. Fall back to a minimized one only if
        // no non-minimized non-cloaked window qualifies. This matters mainly
        // on macOS where `activate` can fire while the window is still in
        // STATE_MINIMIZED (bug 2007691); on Linux/Windows it keeps us from
        // returning null when the only eligible window happens to be
        // minimized.
        if (win.windowState == win.STATE_MINIMIZED) {
          minimizedWin ??= win;
          continue;
        }
        return win;
      }
    }
    // No non-minimized non-cloaked window matched. Prefer a minimized window
    // on the current desktop over a cloaked one from another virtual desktop.
    return minimizedWin || cloakedWin;
  },

  /**
   * Get a window that is in the process of loading. Only supports windows
   * opened via the `openWindow` function in this module or that have been
   * registered with the `registerOpeningWindow` function.
   *
   * @param {object} options
   *   Options for the search.
   * @param {boolean} [options.private]
   *   true to restrict the search to private windows only, false to restrict
   *   the search to non-private only. Omit the property to search in both
   *   groups.
   *
   * @returns {Promise<Window> | null}
   */
  getPendingWindow(options = {}) {
    for (let pending of this.pendingWindows.values()) {
      if (
        !("private" in options) ||
        lazy.PrivateBrowsingUtils.permanentPrivateBrowsing ||
        pending.isPrivate == options.private
      ) {
        return pending.deferred.promise;
      }
    }
    return null;
  },

  /**
   * Registers a browser window that is in the process of opening. Normally it
   * would be preferable to use the standard method for opening the window from
   * this module.
   *
   * @param {Window} window
   *   The opening window.
   * @param {boolean} isPrivate
   *   Whether the opening window is a private browsing window.
   */
  registerOpeningWindow(window, isPrivate) {
    let deferred = Promise.withResolvers();

    this.pendingWindows.set(window, {
      isPrivate,
      deferred,
    });

    // Prevent leaks in case the window closes before we track it as an open
    // window.
    const topic = "browsing-context-discarded";
    const observer = aSubject => {
      if (window.browsingContext == aSubject) {
        let pending = this.pendingWindows.get(window);
        if (pending) {
          this.pendingWindows.delete(window);
          pending.deferred.resolve(window);
        }
        Services.obs.removeObserver(observer, topic);
      }
    };
    Services.obs.addObserver(observer, topic);
  },

  /**
   * A standard function for opening a new browser window.
   *
   * @param {object} [options]
   *   Options for the new window.
   * @param {Window} [options.openerWindow]
   *   An existing browser window to open the new one from.
   * @param {boolean} [options.private]
   *   True to make the window a private browsing window.
   * @param {boolean} [options.aiWindow]
   *   True to make the window an AI browsing window.
   * @param {string} [options.features]
   *   Additional window features to give the new window.
   * @param {boolean} [options.all]
   *   True if "all" should be included as a window feature. If omitted, defaults
   *   to true.
   * @param {nsIArray | nsISupportsString} [options.args]
   *   Arguments to pass to the new window.
   * @param {boolean} [options.remote]
   *   A boolean indicating if the window should run remote browser tabs or
   *   not. If omitted, the window  will choose the profile default state.
   * @param {boolean} [options.fission]
   *   A boolean indicating if the window should run with fission enabled or
   *   not. If omitted, the window will choose the profile default state.
   *
   * @returns {Window}
   */
  openWindow(options = {}) {
    let {
      openerWindow = undefined,
      private: isPrivate = false,
      features = undefined,
      all = true,
      args = null,
      remote = undefined,
      fission = undefined,
      skipCustomHome = false,
    } = options;

    args = lazy.AIWindow.handleAIWindowOptions(options);

    let windowFeatures = "chrome,dialog=no";
    if (all) {
      windowFeatures += ",all";
    }
    if (features) {
      windowFeatures += `,${features}`;
    }
    let loadURIString;
    if (isPrivate && lazy.PrivateBrowsingUtils.enabled) {
      windowFeatures += ",private";
      if (
        (!args && !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) ||
        skipCustomHome
      ) {
        // Force the new window to load about:privatebrowsing instead of the
        // default home page.
        loadURIString = "about:privatebrowsing";
      }
    } else {
      windowFeatures += ",non-private";
    }
    if (!args) {
      loadURIString ??= lazy.BrowserHandler.defaultArgs;
      args = Cc["@mozilla.org/supports-string;1"].createInstance(
        Ci.nsISupportsString
      );
      args.data = loadURIString;
    }

    if (remote) {
      windowFeatures += ",remote";
    } else if (remote === false) {
      windowFeatures += ",non-remote";
    }

    if (fission) {
      windowFeatures += ",fission";
    } else if (fission === false) {
      windowFeatures += ",non-fission";
    }

    // If the opener window is maximized, we want to skip the animation, since
    // we're going to be taking up most of the screen anyways, and we want to
    // optimize for showing the user a useful window as soon as possible.
    if (openerWindow?.windowState == openerWindow?.STATE_MAXIMIZED) {
      windowFeatures += ",suppressanimation";
    }

    let win = Services.ww.openWindow(
      openerWindow,
      AppConstants.BROWSER_CHROME_URL,
      "_blank",
      windowFeatures,
      args
    );
    this.registerOpeningWindow(win, isPrivate);

    win.addEventListener(
      "MozAfterPaint",
      () => {
        if (
          Services.prefs.getIntPref("browser.startup.page") == 1 &&
          loadURIString == lazy.HomePage.get()
        ) {
          // A notification for when a user has triggered their homepage. This
          // is used to display a doorhanger explaining that an extension has
          // modified the homepage, if necessary.
          Services.obs.notifyObservers(win, "browser-open-homepage-start");
        }
      },
      { once: true }
    );

    return win;
  },

  /**
   * Async version of `openWindow` waiting for delayed startup of the new
   * window before returning.
   *
   * @param {object} [options]
   *   Options for the new window. See `openWindow` for details.
   *
   * @returns {Window}
   */
  async promiseOpenWindow(options) {
    let win = this.openWindow(options);
    await topicObserved(
      "browser-delayed-startup-finished",
      subject => subject == win
    );
    return win;
  },

  /**
   * Number of currently open browser windows.
   */
  get windowCount() {
    return _trackedWindows.length;
  },

  get orderedWindows() {
    return this.getOrderedWindows();
  },

  /**
   * Array of browser windows ordered by z-index, in reverse order.
   * This means that the top-most browser window will be the first item.
   *
   * @param {object} options
   * @param {boolean}  [options.private]
   *   If set, returns only windows with the specified privateness. i.e. `true`
   *   will return only private windows. The default value, `null`, will return
   *   all windows.
   */
  getOrderedWindows({ private: isPrivate = undefined } = {}) {
    const nonMinimized = [];
    const minimized = [];
    for (const w of _trackedWindows) {
      if (w.windowState == w.STATE_MINIMIZED) {
        minimized.push(w);
      } else {
        nonMinimized.push(w);
      }
    }
    // Move minimized windows to the back while preserving the relative order
    // of each group, so consumers prefer non-minimized windows. See bug
    // 2007691. concat() also returns a fresh array, so callers do not get a
    // reference to the internal _trackedWindows list.
    let windows = nonMinimized.concat(minimized);

    if (
      typeof isPrivate !== "boolean" ||
      (isPrivate && lazy.PrivateBrowsingUtils.permanentPrivateBrowsing)
    ) {
      return windows;
    }

    return windows.filter(
      w => lazy.PrivateBrowsingUtils.isWindowPrivate(w) === isPrivate
    );
  },

  getAllVisibleTabs() {
    let tabs = [];
    for (let win of BrowserWindowTracker.orderedWindows) {
      for (let tab of win.gBrowser.visibleTabs) {
        // Only use tabs which are not discarded / unrestored
        if (tab.linkedPanel) {
          let { contentTitle, browserId } = tab.linkedBrowser;
          tabs.push({ contentTitle, browserId });
        }
      }
    }
    return tabs;
  },

  track(window) {
    let pending = this.pendingWindows.get(window);
    if (pending) {
      this.pendingWindows.delete(window);
      // Waiting for delayed startup to complete ensures that this new window
      // has started loading its initial urls.
      window.delayedStartupPromise.then(() => pending.deferred.resolve(window));
    }

    return WindowHelper.addWindow(window);
  },

  getBrowserById(browserId) {
    for (let win of BrowserWindowTracker.orderedWindows) {
      for (let tab of win.gBrowser.visibleTabs) {
        if (tab.linkedPanel && tab.linkedBrowser.browserId === browserId) {
          return tab.linkedBrowser;
        }
      }
    }
    return null;
  },

  // For tests only, this function will remove this window from the list of
  // tracked windows. Please don't forget to add it back at the end of your
  // tests using BrowserWindowTracker.track(window)!
  untrackForTestsOnly(window) {
    return WindowHelper.removeWindow(window);
  },
};
