/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
 * 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";

const lazy = {};

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

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gTabWarmingEnabled",
  "browser.tabs.remote.warmup.enabled"
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gTabWarmingMax",
  "browser.tabs.remote.warmup.maxTabs"
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gTabWarmingUnloadDelayMs",
  "browser.tabs.remote.warmup.unloadDelayMs"
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gTabCacheSize",
  "browser.tabs.remote.tabCacheSize"
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gTabUnloadDelay",
  "browser.tabs.remote.unloadDelayMs",
  300
);

/**
 * The tab switcher is responsible for asynchronously switching
 * tabs in e10s. It waits until the new tab is ready (i.e., the
 * layer tree is available) before switching to it. Then it
 * unloads the layer tree for the old tab.
 *
 * The tab switcher is a state machine. For each tab, it
 * maintains state about whether the layer tree for the tab is
 * available, being loaded, being unloaded, or unavailable. It
 * also keeps track of the tab currently being displayed, the tab
 * it's trying to load, and the tab the user has asked to switch
 * to. The switcher object is created upon tab switch. It is
 * released when there are no pending tabs to load or unload.
 *
 * The following general principles have guided the design:
 *
 * 1. We only request one layer tree at a time. If the user
 * switches to a different tab while waiting, we don't request
 * the new layer tree until the old tab has loaded or timed out.
 *
 * 2. If loading the layers for a tab times out, we show the
 * spinner and possibly request the layer tree for another tab if
 * the user has requested one.
 *
 * 3. We discard layer trees on a delay. This way, if the user is
 * switching among the same tabs frequently, we don't continually
 * load the same tabs.
 *
 * It's important that we always show either the spinner or a tab
 * whose layers are available. Otherwise the compositor will draw
 * an entirely black frame, which is very jarring. To ensure this
 * never happens when switching away from a tab, we assume the
 * old tab might still be drawn until a MozAfterPaint event
 * occurs. Because layout and compositing happen asynchronously,
 * we don't have any other way of knowing when the switch
 * actually takes place. Therefore, we don't unload the old tab
 * until the next MozAfterPaint event.
 */
export class AsyncTabSwitcher {
  constructor(tabbrowser) {
    this.log("START");

    // How long to wait for a tab's layers to load. After this
    // time elapses, we're free to put up the spinner and start
    // trying to load a different tab.
    this.TAB_SWITCH_TIMEOUT = 400; // ms

    // When the user hasn't switched tabs for this long, we unload
    // layers for all tabs that aren't in use.
    this.UNLOAD_DELAY = lazy.gTabUnloadDelay; // ms

    // The next three tabs form the principal state variables.
    // See the assertions in postActions for their invariants.

    // Tab the user requested most recently.
    this.requestedTab = tabbrowser.selectedTab;

    // Tab we're currently trying to load.
    this.loadingTab = null;

    // We show this tab in case the requestedTab hasn't loaded yet.
    this.lastVisibleTab = tabbrowser.selectedTab;

    // Auxilliary state variables:

    this.visibleTab = tabbrowser.selectedTab; // Tab that's on screen.
    this.spinnerTab = null; // Tab showing a spinner.
    this.blankTab = null; // Tab showing blank.
    this.lastPrimaryTab = tabbrowser.selectedTab; // Tab with primary="true"

    this.tabbrowser = tabbrowser;
    this.window = tabbrowser.ownerGlobal;
    this.loadTimer = null; // TAB_SWITCH_TIMEOUT nsITimer instance.
    this.unloadTimer = null; // UNLOAD_DELAY nsITimer instance.

    // Map from tabs to STATE_* (below).
    this.tabState = new Map();

    // True if we're in the midst of switching tabs.
    this.switchInProgress = false;

    // Transaction id for the composite that will show the requested
    // tab for the first tab after a tab switch.
    // Set to -1 when we're not waiting for notification of a
    // completed switch.
    this.switchPaintId = -1;

    // Set of tabs that might be visible right now. We maintain
    // this set because we can't be sure when a tab is actually
    // drawn. A tab is added to this set when we ask to make it
    // visible. All tabs but the most recently shown tab are
    // removed from the set upon MozAfterPaint.
    this.maybeVisibleTabs = new Set([tabbrowser.selectedTab]);

    // This holds onto the set of tabs that we've been asked to warm up,
    // and tabs are evicted once they're done loading or are unloaded.
    this.warmingTabs = new WeakSet();

    this.STATE_UNLOADED = 0;
    this.STATE_LOADING = 1;
    this.STATE_LOADED = 2;
    this.STATE_UNLOADING = 3;

    // re-entrancy guard:
    this._processing = false;

    // For telemetry, keeps track of what most recently cleared
    // the loadTimer, which can tell us something about the cause
    // of tab switch spinners.
    this._loadTimerClearedBy = "none";

    this._useDumpForLogging = false;
    this._logInit = false;
    this._logFlags = [];

    this.window.addEventListener("MozAfterPaint", this);
    this.window.addEventListener("MozLayerTreeReady", this);
    this.window.addEventListener("MozLayerTreeCleared", this);
    this.window.addEventListener("TabRemotenessChange", this);
    this.window.addEventListener("SwapDocShells", this, true);
    this.window.addEventListener("EndSwapDocShells", this, true);
    this.window.document.addEventListener("visibilitychange", this);

    let initialTab = this.requestedTab;
    let initialBrowser = initialTab.linkedBrowser;

    let tabIsLoaded =
      !initialBrowser.isRemoteBrowser ||
      initialBrowser.frameLoader.remoteTab?.hasLayers;

    // If we minimized the window before the switcher was activated,
    // we might have set  the preserveLayers flag for the current
    // browser. Let's clear it.
    initialBrowser.preserveLayers(false);

    if (!this.windowHidden) {
      this.log("Initial tab is loaded?: " + tabIsLoaded);
      this.setTabState(
        initialTab,
        tabIsLoaded ? this.STATE_LOADED : this.STATE_LOADING
      );
    }

    for (let ppBrowser of this.tabbrowser._printPreviewBrowsers) {
      let ppTab = this.tabbrowser.getTabForBrowser(ppBrowser);
      let state = ppBrowser.hasLayers ? this.STATE_LOADED : this.STATE_LOADING;
      this.setTabState(ppTab, state);
    }
  }

  destroy() {
    if (this.unloadTimer) {
      this.clearTimer(this.unloadTimer);
      this.unloadTimer = null;
    }
    if (this.loadTimer) {
      this.clearTimer(this.loadTimer);
      this.loadTimer = null;
    }

    this.window.removeEventListener("MozAfterPaint", this);
    this.window.removeEventListener("MozLayerTreeReady", this);
    this.window.removeEventListener("MozLayerTreeCleared", this);
    this.window.removeEventListener("TabRemotenessChange", this);
    this.window.removeEventListener("SwapDocShells", this, true);
    this.window.removeEventListener("EndSwapDocShells", this, true);
    this.window.document.removeEventListener("visibilitychange", this);

    this.tabbrowser._switcher = null;
  }

  // Wraps nsITimer. Must not use the vanilla setTimeout and
  // clearTimeout, because they will be blocked by nsIPromptService
  // dialogs.
  setTimer(callback, timeout) {
    let event = {
      notify: callback,
    };

    var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    timer.initWithCallback(event, timeout, Ci.nsITimer.TYPE_ONE_SHOT);
    return timer;
  }

  clearTimer(timer) {
    timer.cancel();
  }

  getTabState(tab) {
    let state = this.tabState.get(tab);

    // As an optimization, we lazily evaluate the state of tabs
    // that we've never seen before. Once we've figured it out,
    // we stash it in our state map.
    if (state === undefined) {
      state = this.STATE_UNLOADED;

      if (tab && tab.linkedPanel) {
        let b = tab.linkedBrowser;
        if (b.renderLayers && b.hasLayers) {
          state = this.STATE_LOADED;
        } else if (b.renderLayers && !b.hasLayers) {
          state = this.STATE_LOADING;
        } else if (!b.renderLayers && b.hasLayers) {
          state = this.STATE_UNLOADING;
        }
      }

      this.setTabStateNoAction(tab, state);
    }

    return state;
  }

  setTabStateNoAction(tab, state) {
    if (state == this.STATE_UNLOADED) {
      this.tabState.delete(tab);
    } else {
      this.tabState.set(tab, state);
    }
  }

  setTabState(tab, state) {
    if (state == this.getTabState(tab)) {
      return;
    }

    this.setTabStateNoAction(tab, state);

    let browser = tab.linkedBrowser;
    let remoteTab = browser.frameLoader?.remoteTab;
    if (state == this.STATE_LOADING) {
      this.assert(!this.windowHidden);

      // If we're not in the process of warming this tab, we
      // don't need to delay activating its DocShell.
      if (!this.warmingTabs.has(tab)) {
        browser.docShellIsActive = true;
      }

      if (remoteTab) {
        browser.renderLayers = true;
        remoteTab.priorityHint = true;
      }
      if (browser.hasLayers) {
        this.onLayersReady(browser);
      }
    } else if (state == this.STATE_UNLOADING) {
      this.unwarmTab(tab);
      // Setting the docShell to be inactive will also cause it
      // to stop rendering layers.
      browser.docShellIsActive = false;
      if (remoteTab) {
        remoteTab.priorityHint = false;
      }
      if (!browser.hasLayers) {
        this.onLayersCleared(browser);
      }
    } else if (state == this.STATE_LOADED) {
      this.maybeActivateDocShell(tab);
    }

    if (!tab.linkedBrowser.isRemoteBrowser) {
      // setTabState is potentially re-entrant, so we must re-get the state for
      // this assertion.
      let nonRemoteState = this.getTabState(tab);
      // Non-remote tabs can never stay in the STATE_LOADING
      // or STATE_UNLOADING states. By the time this function
      // exits, a non-remote tab must be in STATE_LOADED or
      // STATE_UNLOADED, since the painting and the layer
      // upload happen synchronously.
      this.assert(
        nonRemoteState == this.STATE_UNLOADED ||
          nonRemoteState == this.STATE_LOADED
      );
    }
  }

  get windowHidden() {
    return this.window.document.hidden;
  }

  get tabLayerCache() {
    return this.tabbrowser._tabLayerCache;
  }

  finish() {
    this.log("FINISH");

    this.assert(this.tabbrowser._switcher);
    this.assert(this.tabbrowser._switcher === this);
    this.assert(!this.spinnerTab);
    this.assert(!this.blankTab);
    this.assert(!this.loadTimer);
    this.assert(!this.loadingTab);
    this.assert(this.lastVisibleTab === this.requestedTab);
    this.assert(
      this.windowHidden ||
        this.getTabState(this.requestedTab) == this.STATE_LOADED
    );

    this.destroy();

    this.window.document.commandDispatcher.unlock();

    let event = new this.window.CustomEvent("TabSwitchDone", {
      bubbles: true,
      cancelable: true,
    });
    this.tabbrowser.dispatchEvent(event);
  }

  // This function is called after all the main state changes to
  // make sure we display the right tab.
  updateDisplay() {
    let requestedTabState = this.getTabState(this.requestedTab);
    let requestedBrowser = this.requestedTab.linkedBrowser;

    // It is often more desirable to show a blank tab when appropriate than
    // the tab switch spinner - especially since the spinner is usually
    // preceded by a perceived lag of TAB_SWITCH_TIMEOUT ms in the
    // tab switch. We can hide this lag, and hide the time being spent
    // constructing BrowserChild's, layer trees, etc, by showing a blank
    // tab instead and focusing it immediately.
    let shouldBeBlank = false;
    if (requestedBrowser.isRemoteBrowser) {
      // If a tab is remote and the window is not minimized, we can show a
      // blank tab instead of a spinner in the following cases:
      //
      // 1. The tab has just crashed, and we haven't started showing the
      //    tab crashed page yet (in this case, the RemoteTab is null)
      // 2. The tab has never presented, and has not finished loading
      //    a non-local-about: page.
      //
      // For (2), "finished loading a non-local-about: page" is
      // determined by the busy state on the tab element and checking
      // if the loaded URI is local.
      let isBusy = this.requestedTab.hasAttribute("busy");
      let isLocalAbout = requestedBrowser.currentURI.schemeIs("about");
      let hasSufficientlyLoaded = !isBusy && !isLocalAbout;

      let fl = requestedBrowser.frameLoader;
      shouldBeBlank =
        !this.windowHidden &&
        (!fl.remoteTab ||
          (!hasSufficientlyLoaded && !fl.remoteTab.hasPresented));

      if (this.logging()) {
        let flag = shouldBeBlank ? "blank" : "nonblank";
        this.addLogFlag(
          flag,
          this.windowHidden,
          fl.remoteTab,
          isBusy,
          isLocalAbout,
          fl.remoteTab ? fl.remoteTab.hasPresented : 0
        );
      }
    }

    if (requestedBrowser.isRemoteBrowser) {
      this.addLogFlag("isRemote");
    }

    // Figure out which tab we actually want visible right now.
    let showTab = null;
    if (
      requestedTabState != this.STATE_LOADED &&
      this.lastVisibleTab &&
      this.loadTimer &&
      !shouldBeBlank
    ) {
      // If we can't show the requestedTab, and lastVisibleTab is
      // available, show it.
      showTab = this.lastVisibleTab;
    } else {
      // Show the requested tab. If it's not available, we'll show the spinner or a blank tab.
      showTab = this.requestedTab;
    }

    // First, let's deal with blank tabs, which we show instead
    // of the spinner when the tab is not currently set up
    // properly in the content process.
    if (!shouldBeBlank && this.blankTab) {
      this.blankTab.linkedBrowser.removeAttribute("blank");
      this.blankTab = null;
    } else if (shouldBeBlank && this.blankTab !== showTab) {
      if (this.blankTab) {
        this.blankTab.linkedBrowser.removeAttribute("blank");
      }
      this.blankTab = showTab;
      this.blankTab.linkedBrowser.setAttribute("blank", "true");
    }

    // Show or hide the spinner as needed.
    let needSpinner =
      this.getTabState(showTab) != this.STATE_LOADED &&
      !this.windowHidden &&
      !shouldBeBlank &&
      !this.loadTimer;

    if (!needSpinner && this.spinnerTab) {
      this.noteSpinnerHidden();
      this.tabbrowser.tabpanels.removeAttribute("pendingpaint");
      this.spinnerTab.linkedBrowser.removeAttribute("pendingpaint");
      this.spinnerTab = null;
    } else if (needSpinner && this.spinnerTab !== showTab) {
      if (this.spinnerTab) {
        this.spinnerTab.linkedBrowser.removeAttribute("pendingpaint");
      } else {
        this.noteSpinnerDisplayed();
      }
      this.spinnerTab = showTab;
      this.tabbrowser.tabpanels.toggleAttribute("pendingpaint", true);
      this.spinnerTab.linkedBrowser.toggleAttribute("pendingpaint", true);
    }

    // Switch to the tab we've decided to make visible.
    if (this.visibleTab !== showTab) {
      this.tabbrowser._adjustFocusBeforeTabSwitch(this.visibleTab, showTab);
      this.visibleTab = showTab;

      this.maybeVisibleTabs.add(showTab);

      let tabpanels = this.tabbrowser.tabpanels;
      let showPanel = this.tabbrowser.tabContainer.getRelatedElement(showTab);
      let index = Array.prototype.indexOf.call(tabpanels.children, showPanel);
      if (index != -1) {
        this.log(`Switch to tab ${index} - ${this.tinfo(showTab)}`);
        tabpanels.updateSelectedIndex(index);
        if (showTab === this.requestedTab) {
          if (requestedTabState == this.STATE_LOADED) {
            // The new tab will be made visible in the next paint, record the expected
            // transaction id for that, and we'll mark when we get notified of its
            // completion.
            this.switchPaintId = this.window.windowUtils.lastTransactionId + 1;
          } else {
            this.noteMakingTabVisibleWithoutLayers();
          }

          this.tabbrowser._adjustFocusAfterTabSwitch(showTab);
          this.window.gURLBar.afterTabSwitchFocusChange();
          this.maybeActivateDocShell(this.requestedTab);
        }
      }

      // This doesn't necessarily exist if we're a new window and haven't switched tabs yet
      if (this.lastVisibleTab) {
        this.lastVisibleTab._visuallySelected = false;
      }

      this.visibleTab._visuallySelected = true;
    }

    this.lastVisibleTab = this.visibleTab;
  }

  assert(cond) {
    if (!cond) {
      dump("Assertion failure\n" + Error().stack);

      // Don't break a user's browser if an assertion fails.
      if (AppConstants.DEBUG) {
        throw new Error("Assertion failure");
      }
    }
  }

  maybeClearLoadTimer(caller) {
    if (this.loadingTab) {
      this._loadTimerClearedBy = caller;
      this.loadingTab = null;
      if (this.loadTimer) {
        this.clearTimer(this.loadTimer);
        this.loadTimer = null;
      }
    }
  }

  // We've decided to try to load requestedTab.
  loadRequestedTab() {
    this.assert(!this.loadTimer);
    this.assert(!this.windowHidden);

    // loadingTab can be non-null here if we timed out loading the current tab.
    // In that case we just overwrite it with a different tab; it's had its chance.
    this.loadingTab = this.requestedTab;
    this.log("Loading tab " + this.tinfo(this.loadingTab));

    this.loadTimer = this.setTimer(
      () => this.handleEvent({ type: "loadTimeout" }),
      this.TAB_SWITCH_TIMEOUT
    );
    this.setTabState(this.requestedTab, this.STATE_LOADING);
  }

  maybeActivateDocShell(tab) {
    // If we've reached the point where the requested tab has entered
    // the loaded state, but the DocShell is still not yet active, we
    // should activate it.
    let browser = tab.linkedBrowser;
    let state = this.getTabState(tab);
    let canCheckDocShellState =
      !browser.mDestroyed &&
      (browser.docShell || browser.frameLoader.remoteTab);
    if (
      tab == this.requestedTab &&
      canCheckDocShellState &&
      state == this.STATE_LOADED &&
      !browser.docShellIsActive &&
      !this.windowHidden
    ) {
      browser.docShellIsActive = true;
      this.logState(
        "Set requested tab docshell to active and preserveLayers to false"
      );
      // If we minimized the window before the switcher was activated,
      // we might have set the preserveLayers flag for the current
      // browser. Let's clear it.
      browser.preserveLayers(false);
    }
  }

  // This function runs before every event. It fixes up the state
  // to account for closed tabs.
  preActions() {
    this.assert(this.tabbrowser._switcher);
    this.assert(this.tabbrowser._switcher === this);

    for (let i = 0; i < this.tabLayerCache.length; i++) {
      let tab = this.tabLayerCache[i];
      if (!tab.linkedBrowser) {
        this.tabState.delete(tab);
        this.tabLayerCache.splice(i, 1);
        i--;
      }
    }

    for (let [tab] of this.tabState) {
      if (!tab.linkedBrowser) {
        this.tabState.delete(tab);
        this.unwarmTab(tab);
      }
    }

    if (this.lastVisibleTab && !this.lastVisibleTab.linkedBrowser) {
      this.lastVisibleTab = null;
    }
    if (this.lastPrimaryTab && !this.lastPrimaryTab.linkedBrowser) {
      this.lastPrimaryTab = null;
    }
    if (this.blankTab && !this.blankTab.linkedBrowser) {
      this.blankTab = null;
    }
    if (this.spinnerTab && !this.spinnerTab.linkedBrowser) {
      this.noteSpinnerHidden();
      this.spinnerTab = null;
    }
    if (this.loadingTab && !this.loadingTab.linkedBrowser) {
      this.maybeClearLoadTimer("preActions");
    }
  }

  // This code runs after we've responded to an event or requested a new
  // tab. It's expected that we've already updated all the principal
  // state variables. This function takes care of updating any auxilliary
  // state.
  postActions(eventString) {
    // Once we finish loading loadingTab, we null it out. So the state should
    // always be LOADING.
    this.assert(
      !this.loadingTab ||
        this.getTabState(this.loadingTab) == this.STATE_LOADING
    );

    // We guarantee that loadingTab is non-null iff loadTimer is non-null. So
    // the timer is set only when we're loading something.
    this.assert(!this.loadTimer || this.loadingTab);
    this.assert(!this.loadingTab || this.loadTimer);

    // If we're switching to a non-remote tab, there's no need to wait
    // for it to send layers to the compositor, as this will happen
    // synchronously. Clearing this here means that in the next step,
    // we can load the non-remote browser immediately.
    if (!this.requestedTab.linkedBrowser.isRemoteBrowser) {
      this.maybeClearLoadTimer("postActions");
    }

    // If we're not loading anything, try loading the requested tab.
    let stateOfRequestedTab = this.getTabState(this.requestedTab);
    if (
      !this.loadTimer &&
      !this.windowHidden &&
      (stateOfRequestedTab == this.STATE_UNLOADED ||
        stateOfRequestedTab == this.STATE_UNLOADING ||
        this.warmingTabs.has(this.requestedTab))
    ) {
      this.assert(stateOfRequestedTab != this.STATE_LOADED);
      this.loadRequestedTab();
    }

    let numBackgroundCached = 0;
    for (let tab of this.tabLayerCache) {
      if (tab !== this.requestedTab) {
        numBackgroundCached++;
      }
    }

    // See how many tabs still have work to do.
    let numPending = 0;
    let numWarming = 0;
    for (let [tab, state] of this.tabState) {
      // In certain cases, tabs that are backgrounded should stay in the
      // STATE_LOADED state, as some mechanisms rely on background rendering.
      // See shouldDeactivateDocShell for the specific cases being handled.
      //
      // This means that if a tab is in STATE_LOADED and we're not going to
      // deactivate it, we shouldn't count it towards numPending. If, however,
      // it's in some other state (say, STATE_LOADING), then we _do_ want to
      // count it as numPending, since we're still waiting on it to be
      // composited.
      if (
        state == this.STATE_LOADED &&
        !this.shouldDeactivateDocShell(tab.linkedBrowser)
      ) {
        continue;
      }

      if (
        state == this.STATE_LOADED &&
        tab !== this.requestedTab &&
        !this.tabLayerCache.includes(tab)
      ) {
        numPending++;

        if (tab !== this.visibleTab) {
          numWarming++;
        }
      }
      if (state == this.STATE_LOADING || state == this.STATE_UNLOADING) {
        numPending++;
      }
    }

    this.updateDisplay();

    // It's possible for updateDisplay to trigger one of our own event
    // handlers, which might cause finish() to already have been called.
    // Check for that before calling finish() again.
    if (!this.tabbrowser._switcher) {
      return;
    }

    this.maybeFinishTabSwitch();

    if (numBackgroundCached > 0) {
      this.deactivateCachedBackgroundTabs();
    }

    if (numWarming > lazy.gTabWarmingMax) {
      this.logState("Hit tabWarmingMax");
      if (this.unloadTimer) {
        this.clearTimer(this.unloadTimer);
      }
      this.unloadNonRequiredTabs();
    }

    if (numPending == 0) {
      this.finish();
    }

    this.logState("/" + eventString);
  }

  // Fires when we're ready to unload unused tabs.
  onUnloadTimeout() {
    this.unloadTimer = null;
    this.unloadNonRequiredTabs();
  }

  deactivateCachedBackgroundTabs() {
    for (let tab of this.tabLayerCache) {
      if (tab !== this.requestedTab) {
        let browser = tab.linkedBrowser;
        browser.preserveLayers(true);
        browser.docShellIsActive = false;
      }
    }
  }

  // If there are any non-visible and non-requested tabs in
  // STATE_LOADED, sets them to STATE_UNLOADING. Also queues
  // up the unloadTimer to run onUnloadTimeout if there are still
  // tabs in the process of unloading.
  unloadNonRequiredTabs() {
    this.warmingTabs = new WeakSet();
    let numPending = 0;

    // Unload any tabs that can be unloaded.
    for (let [tab, state] of this.tabState) {
      if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) {
        continue;
      }

      let isInLayerCache = this.tabLayerCache.includes(tab);

      if (
        state == this.STATE_LOADED &&
        !this.maybeVisibleTabs.has(tab) &&
        tab !== this.lastVisibleTab &&
        tab !== this.loadingTab &&
        tab !== this.requestedTab &&
        !isInLayerCache
      ) {
        this.setTabState(tab, this.STATE_UNLOADING);
      }

      if (
        state != this.STATE_UNLOADED &&
        tab !== this.requestedTab &&
        !isInLayerCache
      ) {
        numPending++;
      }
    }

    if (numPending) {
      // Keep the timer going since there may be more tabs to unload.
      this.unloadTimer = this.setTimer(
        () => this.handleEvent({ type: "unloadTimeout" }),
        this.UNLOAD_DELAY
      );
    }
  }

  // Fires when an ongoing load has taken too long.
  onLoadTimeout() {
    this.maybeClearLoadTimer("onLoadTimeout");
  }

  // Fires when the layers become available for a tab.
  onLayersReady(browser) {
    let tab = this.tabbrowser.getTabForBrowser(browser);
    if (!tab) {
      // We probably got a layer update from a tab that got before
      // the switcher was created, or for browser that's not being
      // tracked by the async tab switcher (like the preloaded about:newtab).
      return;
    }

    this.logState(`onLayersReady(${tab._tPos}, ${browser.isRemoteBrowser})`);
    this.assert(
      this.getTabState(tab) == this.STATE_LOADING ||
        this.getTabState(tab) == this.STATE_LOADED
    );
    this.setTabState(tab, this.STATE_LOADED);
    this.unwarmTab(tab);

    if (this.loadingTab === tab) {
      this.maybeClearLoadTimer("onLayersReady");
    }
  }

  // Fires when we paint the screen. Any tab switches we initiated
  // previously are done, so there's no need to keep the old layers
  // around.
  onPaint(event) {
    this.addLogFlag(
      "onPaint",
      this.switchPaintId != -1,
      event.transactionId >= this.switchPaintId
    );
    this.notePaint(event);
    this.maybeVisibleTabs.clear();
  }

  // Called when we're done clearing the layers for a tab.
  onLayersCleared(browser) {
    let tab = this.tabbrowser.getTabForBrowser(browser);
    if (!tab) {
      return;
    }
    this.logState(`onLayersCleared(${tab._tPos})`);
    this.assert(
      this.getTabState(tab) == this.STATE_UNLOADING ||
        this.getTabState(tab) == this.STATE_UNLOADED
    );
    this.setTabState(tab, this.STATE_UNLOADED);
  }

  // Called when a tab switches from remote to non-remote. In this case
  // a MozLayerTreeReady notification that we requested may never fire,
  // so we need to simulate it.
  onRemotenessChange(tab) {
    this.logState(
      `onRemotenessChange(${tab._tPos}, ${tab.linkedBrowser.isRemoteBrowser})`
    );
    if (!tab.linkedBrowser.isRemoteBrowser) {
      if (this.getTabState(tab) == this.STATE_LOADING) {
        this.onLayersReady(tab.linkedBrowser);
      } else if (this.getTabState(tab) == this.STATE_UNLOADING) {
        this.onLayersCleared(tab.linkedBrowser);
      }
    } else if (this.getTabState(tab) == this.STATE_LOADED) {
      // A tab just changed from non-remote to remote, which means
      // that it's gone back into the STATE_LOADING state until
      // it sends up a layer tree.
      this.setTabState(tab, this.STATE_LOADING);
    }
  }

  onTabRemoved(tab) {
    if (this.lastVisibleTab == tab) {
      this.handleEvent({ type: "tabRemoved", tab });
    }
  }

  // Called when a tab has been removed, and the browser node is
  // about to be removed from the DOM.
  onTabRemovedImpl() {
    this.lastVisibleTab = null;
  }

  onVisibilityChange() {
    if (this.windowHidden) {
      for (let [tab, state] of this.tabState) {
        if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) {
          continue;
        }

        if (state == this.STATE_LOADING || state == this.STATE_LOADED) {
          this.setTabState(tab, this.STATE_UNLOADING);
        }
      }
      this.maybeClearLoadTimer("onSizeModeOrOcc");
    } else {
      // We're no longer minimized or occluded. This means we might want
      // to activate the current tab's docShell.
      this.maybeActivateDocShell(this.tabbrowser.selectedTab);
    }
  }

  onSwapDocShells(ourBrowser, otherBrowser) {
    // This event fires before the swap. ourBrowser is from
    // our window. We save the state of otherBrowser since ourBrowser
    // needs to take on that state at the end of the swap.

    let otherTabbrowser = otherBrowser.ownerGlobal.gBrowser;
    let otherState;
    if (otherTabbrowser && otherTabbrowser._switcher) {
      let otherTab = otherTabbrowser.getTabForBrowser(otherBrowser);
      let otherSwitcher = otherTabbrowser._switcher;
      otherState = otherSwitcher.getTabState(otherTab);
    } else {
      otherState = otherBrowser.docShellIsActive
        ? this.STATE_LOADED
        : this.STATE_UNLOADED;
    }
    if (!this.swapMap) {
      this.swapMap = new WeakMap();
    }
    this.swapMap.set(otherBrowser, {
      state: otherState,
    });
  }

  onEndSwapDocShells(ourBrowser, otherBrowser) {
    // The swap has happened. We reset the loadingTab in
    // case it has been swapped. We also set ourBrowser's state
    // to whatever otherBrowser's state was before the swap.

    // Clearing the load timer means that we will
    // immediately display a spinner if ourBrowser isn't
    // ready yet. Typically it will already be ready
    // though. If it's not, we're probably in a new window,
    // in which case we have no other tabs to display anyway.
    this.maybeClearLoadTimer("onEndSwapDocShells");

    let { state: otherState } = this.swapMap.get(otherBrowser);

    this.swapMap.delete(otherBrowser);

    let ourTab = this.tabbrowser.getTabForBrowser(ourBrowser);
    if (ourTab) {
      this.setTabStateNoAction(ourTab, otherState);
    }
  }

  /**
   * Check if the browser should be deactivated. If the browser is a print preview or
   * split view or PiP browser then we won't deactivate it.
   *
   * @param browser The browser to check if it should be deactivated
   * @returns false if a print preview or PiP browser else true
   */
  shouldDeactivateDocShell(browser) {
    return !(
      this.tabbrowser._printPreviewBrowsers.has(browser) ||
      this.tabbrowser.splitViewBrowsers.includes(browser) ||
      lazy.PictureInPicture.isOriginatingBrowser(browser)
    );
  }

  shouldActivateDocShell(browser) {
    let tab = this.tabbrowser.getTabForBrowser(browser);
    let state = this.getTabState(tab);
    return state == this.STATE_LOADING || state == this.STATE_LOADED;
  }

  activateBrowserForPrintPreview(browser) {
    let tab = this.tabbrowser.getTabForBrowser(browser);
    let state = this.getTabState(tab);
    if (state != this.STATE_LOADING && state != this.STATE_LOADED) {
      this.setTabState(tab, this.STATE_LOADING);
      this.logState(
        "Activated browser " + this.tinfo(tab) + " for print preview"
      );
    }
  }

  canWarmTab(tab) {
    if (!lazy.gTabWarmingEnabled) {
      return false;
    }

    if (!tab) {
      return false;
    }

    // If the tab is not yet inserted, closing, not remote,
    // crashed, already visible, or already requested, warming
    // up the tab makes no sense.
    if (
      this.windowHidden ||
      !tab.linkedPanel ||
      tab.closing ||
      !tab.linkedBrowser.isRemoteBrowser ||
      !tab.linkedBrowser.frameLoader.remoteTab
    ) {
      return false;
    }

    return true;
  }

  shouldWarmTab(tab) {
    if (this.canWarmTab(tab)) {
      // Tabs that are already in STATE_LOADING or STATE_LOADED
      // have no need to be warmed up.
      let state = this.getTabState(tab);
      if (state === this.STATE_UNLOADING || state === this.STATE_UNLOADED) {
        return true;
      }
    }

    return false;
  }

  unwarmTab(tab) {
    this.warmingTabs.delete(tab);
  }

  warmupTab(tab) {
    if (!this.shouldWarmTab(tab)) {
      return;
    }

    this.logState("warmupTab " + this.tinfo(tab));

    this.warmingTabs.add(tab);
    this.setTabState(tab, this.STATE_LOADING);
    this.queueUnload(lazy.gTabWarmingUnloadDelayMs);
  }

  cleanUpTabAfterEviction(tab) {
    this.assert(tab !== this.requestedTab);
    let browser = tab.linkedBrowser;
    if (browser) {
      browser.preserveLayers(false);
    }
    this.setTabState(tab, this.STATE_UNLOADING);
  }

  evictOldestTabFromCache() {
    let tab = this.tabLayerCache.shift();
    this.cleanUpTabAfterEviction(tab);
  }

  maybePromoteTabInLayerCache(tab) {
    if (
      lazy.gTabCacheSize > 1 &&
      tab.linkedBrowser.isRemoteBrowser &&
      tab.linkedBrowser.currentURI.spec != "about:blank"
    ) {
      let tabIndex = this.tabLayerCache.indexOf(tab);

      if (tabIndex != -1) {
        this.tabLayerCache.splice(tabIndex, 1);
      }

      this.tabLayerCache.push(tab);

      if (this.tabLayerCache.length > lazy.gTabCacheSize) {
        this.evictOldestTabFromCache();
      }
    }
  }

  // Called when the user asks to switch to a given tab.
  requestTab(tab) {
    if (tab === this.requestedTab) {
      return;
    }

    let tabState = this.getTabState(tab);

    this.logState("requestTab " + this.tinfo(tab));
    this.startTabSwitch();

    let oldBrowser = this.requestedTab.linkedBrowser;
    oldBrowser.deprioritize();
    this.requestedTab = tab;
    if (tabState == this.STATE_LOADED) {
      this.maybeVisibleTabs.clear();
      // We're switching to a tab that is still loaded.
      // Make sure its priority is correct as it may
      // have been deprioritized when it was switched
      // away from (bug 1927609)
      let browser = tab.linkedBrowser;
      let remoteTab = browser.frameLoader?.remoteTab;
      if (remoteTab) {
        remoteTab.priorityHint = true;
      }
    }

    tab.linkedBrowser.setAttribute("primary", "true");
    if (this.lastPrimaryTab && this.lastPrimaryTab != tab) {
      this.lastPrimaryTab.linkedBrowser.removeAttribute("primary");
    }
    this.lastPrimaryTab = tab;

    this.queueUnload(this.UNLOAD_DELAY);
  }

  queueUnload(unloadTimeout) {
    this.handleEvent({ type: "queueUnload", unloadTimeout });
  }

  onQueueUnload(unloadTimeout) {
    if (this.unloadTimer) {
      this.clearTimer(this.unloadTimer);
    }
    this.unloadTimer = this.setTimer(
      () => this.handleEvent({ type: "unloadTimeout" }),
      unloadTimeout
    );
  }

  handleEvent(event, delayed = false) {
    if (this._processing) {
      this.setTimer(() => this.handleEvent(event, true), 0);
      return;
    }
    if (delayed && this.tabbrowser._switcher != this) {
      // if we delayed processing this event, we might be out of date, in which
      // case we drop the delayed events
      return;
    }
    this._processing = true;
    try {
      this.preActions();

      switch (event.type) {
        case "queueUnload":
          this.onQueueUnload(event.unloadTimeout);
          break;
        case "unloadTimeout":
          this.onUnloadTimeout();
          break;
        case "loadTimeout":
          this.onLoadTimeout();
          break;
        case "tabRemoved":
          this.onTabRemovedImpl(event.tab);
          break;
        case "MozLayerTreeReady": {
          let browser = event.originalTarget;
          if (!browser.renderLayers) {
            // By the time we handle this event, it's possible that something
            // else has already set renderLayers to false, in which case this
            // event is stale and we can safely ignore it.
            return;
          }
          this.onLayersReady(browser);
          break;
        }
        case "MozAfterPaint":
          this.onPaint(event);
          break;
        case "MozLayerTreeCleared": {
          let browser = event.originalTarget;
          if (browser.renderLayers) {
            // By the time we handle this event, it's possible that something
            // else has already set renderLayers to true, in which case this
            // event is stale and we can safely ignore it.
            return;
          }
          this.onLayersCleared(browser);
          break;
        }
        case "TabRemotenessChange":
          this.onRemotenessChange(event.target);
          break;
        case "visibilitychange":
          this.onVisibilityChange();
          break;
        case "SwapDocShells":
          this.onSwapDocShells(event.originalTarget, event.detail);
          break;
        case "EndSwapDocShells":
          this.onEndSwapDocShells(event.originalTarget, event.detail);
          break;
      }

      this.postActions(event.type);
    } finally {
      this._processing = false;
    }
  }

  /*
   * Telemetry and Profiler related helpers for recording tab switch
   * timing.
   */

  startTabSwitch() {
    this.noteStartTabSwitch();
    this.switchInProgress = true;
  }

  /**
   * Something has occurred that might mean that we've completed
   * the tab switch (layers are ready, paints are done, spinners
   * are hidden). This checks to make sure all conditions are
   * satisfied, and then records the tab switch as finished.
   */
  maybeFinishTabSwitch() {
    if (
      this.switchInProgress &&
      this.requestedTab &&
      (this.getTabState(this.requestedTab) == this.STATE_LOADED ||
        this.requestedTab === this.blankTab)
    ) {
      if (this.requestedTab !== this.blankTab) {
        this.maybePromoteTabInLayerCache(this.requestedTab);
      }

      this.noteFinishTabSwitch();
      this.switchInProgress = false;

      let event = new this.window.CustomEvent("TabSwitched", {
        bubbles: true,
        detail: {
          tab: this.requestedTab,
        },
      });
      this.tabbrowser.dispatchEvent(event);
    }
  }

  /*
   * Debug related logging for switcher.
   */
  logging() {
    if (this._useDumpForLogging) {
      return true;
    }
    if (this._logInit) {
      return this._shouldLog;
    }
    let result = Services.prefs.getBoolPref(
      "browser.tabs.remote.logSwitchTiming",
      false
    );
    this._shouldLog = result;
    this._logInit = true;
    return this._shouldLog;
  }

  tinfo(tab) {
    if (tab) {
      return tab._tPos + "(" + tab.linkedBrowser.currentURI.spec + ")";
    }
    return "null";
  }

  log(s) {
    if (!this.logging()) {
      return;
    }
    if (this._useDumpForLogging) {
      dump(s + "\n");
    } else {
      Services.console.logStringMessage(s);
    }
  }

  addLogFlag(flag, ...subFlags) {
    if (this.logging()) {
      if (subFlags.length) {
        flag += `(${subFlags.map(f => (f ? 1 : 0)).join("")})`;
      }
      this._logFlags.push(flag);
    }
  }

  logState(suffix) {
    if (!this.logging()) {
      return;
    }

    let getTabString = tab => {
      let tabString = "";

      let state = this.getTabState(tab);
      let isWarming = this.warmingTabs.has(tab);
      let isCached = this.tabLayerCache.includes(tab);
      let isClosing = tab.closing;
      let linkedBrowser = tab.linkedBrowser;
      let isActive = linkedBrowser && linkedBrowser.docShellIsActive;
      let isRendered = linkedBrowser && linkedBrowser.renderLayers;
      let isPiP =
        linkedBrowser &&
        lazy.PictureInPicture.isOriginatingBrowser(linkedBrowser);

      if (tab === this.lastVisibleTab) {
        tabString += "V";
      }
      if (tab === this.loadingTab) {
        tabString += "L";
      }
      if (tab === this.requestedTab) {
        tabString += "R";
      }
      if (tab === this.blankTab) {
        tabString += "B";
      }
      if (this.maybeVisibleTabs.has(tab)) {
        tabString += "M";
      }

      let extraStates = "";
      if (isWarming) {
        extraStates += "W";
      }
      if (isCached) {
        extraStates += "C";
      }
      if (isClosing) {
        extraStates += "X";
      }
      if (isActive) {
        extraStates += "A";
      }
      if (isRendered) {
        extraStates += "R";
      }
      if (isPiP) {
        extraStates += "P";
      }
      if (extraStates != "") {
        tabString += `(${extraStates})`;
      }

      switch (state) {
        case this.STATE_LOADED: {
          tabString += "(loaded)";
          break;
        }
        case this.STATE_LOADING: {
          tabString += "(loading)";
          break;
        }
        case this.STATE_UNLOADING: {
          tabString += "(unloading)";
          break;
        }
        case this.STATE_UNLOADED: {
          tabString += "(unloaded)";
          break;
        }
      }

      return tabString;
    };

    let accum = "";

    // This is a bit tricky to read, but what we're doing here is collapsing
    // identical tab states down to make the overal string shorter and easier
    // to read, and we move all simply unloaded tabs to the back of the list.
    // I.e., we turn
    //   "0:(unloaded) 1:(unloaded) 2:(unloaded) 3:(loaded)""
    // into
    //   "3:(loaded) 0...2:(unloaded)"
    let tabStrings = this.tabbrowser.tabs.map(t => getTabString(t));
    let lastMatch = -1;
    let unloadedTabsStrings = [];
    for (let i = 0; i <= tabStrings.length; i++) {
      if (i > 0) {
        if (i < tabStrings.length && tabStrings[i] == tabStrings[lastMatch]) {
          continue;
        }

        if (tabStrings[lastMatch] == "(unloaded)") {
          if (lastMatch == i - 1) {
            unloadedTabsStrings.push(lastMatch.toString());
          } else {
            unloadedTabsStrings.push(`${lastMatch}...${i - 1}`);
          }
        } else if (lastMatch == i - 1) {
          accum += `${lastMatch}:${tabStrings[lastMatch]} `;
        } else {
          accum += `${lastMatch}...${i - 1}:${tabStrings[lastMatch]} `;
        }
      }

      lastMatch = i;
    }

    if (unloadedTabsStrings.length) {
      accum += `${unloadedTabsStrings.join(",")}:(unloaded) `;
    }

    accum += "cached: " + this.tabLayerCache.length + " ";

    if (this._logFlags.length) {
      accum += `[${this._logFlags.join(",")}] `;
      this._logFlags = [];
    }

    // It can be annoying to read through the entirety of a log string just
    // to check if something changed or not. So if we can tell that nothing
    // changed, just write "unchanged" to save the reader's time.
    let logString;
    if (this._lastLogString == accum) {
      accum = "unchanged";
    } else {
      this._lastLogString = accum;
    }
    logString = `ATS: ${accum}{${suffix}}`;

    if (this._useDumpForLogging) {
      dump(logString + "\n");
    } else {
      Services.console.logStringMessage(logString);
    }
  }

  noteMakingTabVisibleWithoutLayers() {
    // We're making the tab visible even though we haven't yet got layers for it.
    // It's hard to know which composite the layers will first be available in (and
    // the parent process might not even get MozAfterPaint delivered for it), so just
    // give up measuring this for now. :(
    Glean.performanceInteraction.tabSwitchComposite.cancel(
      this._tabswitchCompositeTimerId
    );
    this._tabswitchCompositeTimerId = null;
  }

  notePaint(event) {
    if (this.switchPaintId != -1 && event.transactionId >= this.switchPaintId) {
      if (this._tabswitchCompositeTimerId) {
        Glean.performanceInteraction.tabSwitchComposite.stopAndAccumulate(
          this._tabswitchCompositeTimerId
        );
        this._tabswitchCompositeTimerId = null;
      }
      let { innerWindowId } = this.window.windowGlobalChild;
      ChromeUtils.addProfilerMarker("AsyncTabSwitch:Composited", {
        innerWindowId,
      });
      this.switchPaintId = -1;
    }
  }

  noteStartTabSwitch() {
    if (this._tabswitchTotalTimerId) {
      Glean.browserTabswitch.total.cancel(this._tabswitchTotalTimerId);
    }
    this._tabswitchTotalTimerId = Glean.browserTabswitch.total.start();

    if (this._tabswitchCompositeTimerId) {
      Glean.performanceInteraction.tabSwitchComposite.cancel(
        this._tabswitchCompositeTimerId
      );
    }
    this._tabswitchCompositeTimerId =
      Glean.performanceInteraction.tabSwitchComposite.start();
    let { innerWindowId } = this.window.windowGlobalChild;
    ChromeUtils.addProfilerMarker("AsyncTabSwitch:Start", { innerWindowId });
  }

  noteFinishTabSwitch() {
    // After this point the tab has switched from the content thread's point of view.
    // The changes will be visible after the next refresh driver tick + composite.
    if (this._tabswitchTotalTimerId) {
      Glean.browserTabswitch.total.stopAndAccumulate(
        this._tabswitchTotalTimerId
      );
      this._tabswitchTotalTimerId = null;
      let { innerWindowId } = this.window.windowGlobalChild;
      ChromeUtils.addProfilerMarker("AsyncTabSwitch:Finish", { innerWindowId });
    }
  }

  noteSpinnerDisplayed() {
    this.assert(!this.spinnerTab);
    let browser = this.requestedTab.linkedBrowser;
    this.assert(browser.isRemoteBrowser);
    this._tabswitchSpinnerTimerId =
      Glean.browserTabswitch.spinnerVisible.start();
    let { innerWindowId } = this.window.windowGlobalChild;
    ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerShown", {
      innerWindowId,
    });
    Glean.browserTabswitch.spinnerVisibleTrigger[this._loadTimerClearedBy].add(
      1
    );
    if (AppConstants.NIGHTLY_BUILD) {
      Services.obs.notifyObservers(null, "tabswitch-spinner");
    }
  }

  noteSpinnerHidden() {
    this.assert(this.spinnerTab);
    this.log("DEBUG: spinner hidden");
    Glean.browserTabswitch.spinnerVisible.stopAndAccumulate(
      this._tabswitchSpinnerTimerId
    );
    this._tabswitchSpinnerTimerId = null;
    let { innerWindowId } = this.window.windowGlobalChild;
    ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerHidden", {
      innerWindowId,
    });
    // we do not get a onPaint after displaying the spinner
    this._loadTimerClearedBy = "none";
  }
}
