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

/* eslint-disable consistent-return */

import { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs";

const GLANCE_BACKGROUND_SCALE = 0.97;

/**
 * Manages the Zen Glance feature - a preview overlay system for tabs
 * Allows users to preview content without fully opening new tabs
 */
class nsZenGlanceManager extends nsZenDOMOperatedFeature {
  // Animation state
  _animating = false;
  _lazyPref = {};

  // Glance management
  #glances = new Map();
  #currentGlanceID = null;
  #confirmationTimeout = null;

  // Animation flags
  animatingOpen = false;
  animatingFullOpen = false;
  closingGlance = false;
  #duringOpening = false;
  #ignoreClose = false;

  // Click handling
  #lastLinkClickData = { clientX: 0, clientY: 0, height: 0, width: 0 };

  // Arc animation configuration
  #ARC_CONFIG = Object.freeze({
    ARC_STEPS: 80, // Browser interpolates between keyframes natively
    MAX_ARC_HEIGHT: 25,
    ARC_HEIGHT_RATIO: 0.2, // Arc height = distance * ratio (capped at MAX_ARC_HEIGHT)
  });

  #GLANCE_ANIMATION_DURATION = Services.prefs.getIntPref(
    "zen.glance.animation-duration"
  );

  init() {
    this.#setupEventListeners();
    this.#setupPreferences();
    this.#setupObservers();
    this.#insertIntoContextMenu();
  }

  #setupEventListeners() {
    window.addEventListener("TabClose", this.onTabClose.bind(this));
    window.addEventListener("TabSelect", this.onLocationChange.bind(this));

    document
      .getElementById("tabbrowser-tabpanels")
      .addEventListener("click", this.onOverlayClick.bind(this));
  }

  #setupPreferences() {
    XPCOMUtils.defineLazyPreferenceGetter(
      this._lazyPref,
      "SHOULD_OPEN_EXTERNAL_TABS_IN_GLANCE",
      "zen.glance.open-essential-external-links",
      false
    );
  }

  #setupObservers() {
    Services.obs.addObserver(this, "quit-application-requested");
  }

  #insertIntoContextMenu() {
    const menuitem = document.createXULElement("menuitem");
    menuitem.setAttribute("id", "context-zenOpenLinkInGlance");
    menuitem.setAttribute("hidden", "true");
    menuitem.setAttribute("data-l10n-id", "zen-open-link-in-glance");

    menuitem.addEventListener("command", () =>
      this.openGlance({
        url: gContextMenu.linkURL,
        triggeringPrincipal:
          Services.scriptSecurityManager.getSystemPrincipal(),
      })
    );

    document
      .getElementById("context-sep-open")
      .insertAdjacentElement("beforebegin", menuitem);
  }

  /**
   * Handle main command set events for glance operations
   *
   * @param {Event} event - The command event
   */
  handleMainCommandSet(event) {
    const command = event.target;
    const commandHandlers = {
      cmd_zenGlanceClose: () => this.closeGlance({ onTabClose: true }),
      cmd_zenGlanceExpand: () => this.fullyOpenGlance(),
      cmd_zenGlanceSplit: () => this.splitGlance(),
    };

    const handler = commandHandlers[command.id];
    if (handler) {
      handler();
    }
  }

  /**
   * Get the current glance browser element
   *
   * @returns {Browser} The current browser or null
   */
  get #currentBrowser() {
    return this.#glances.get(this.#currentGlanceID)?.browser;
  }

  /**
   * Get the current glance tab element
   *
   * @returns {Tab} The current tab or null
   */
  get #currentTab() {
    return this.#glances.get(this.#currentGlanceID)?.tab;
  }

  /**
   * Get the current glance parent tab element
   *
   * @returns {Tab} The parent tab or null
   */
  get #currentParentTab() {
    return this.#glances.get(this.#currentGlanceID)?.parentTab;
  }

  /**
   * Handle clicks on the glance overlay
   *
   * @param {Event} event - The click event
   */
  onOverlayClick(event) {
    const isOverlayClick = event.target === this.overlay;
    const isNotContentClick = event.originalTarget !== this.contentWrapper;

    if (isOverlayClick && isNotContentClick) {
      this.closeGlance({ onTabClose: true });
    }
  }

  /**
   * Handle application observer notifications
   *
   * @param {object} subject - The subject of the notification
   * @param {string} topic - The topic of the notification
   */
  observe(subject, topic) {
    if (topic === "quit-application-requested") {
      this.onUnload();
    }
  }

  /**
   * Clean up all glances when the application is unloading
   */
  onUnload() {
    for (const [, glance] of this.#glances) {
      gBrowser.removeTab(glance.tab, { animate: false });
    }
    this.#glances.clear();
  }

  /**
   * Create a new browser element for a glance
   *
   * @param {object} data - Glance data including URL and dimensions
   * @param {Tab} currentTab - The current tab
   * @param {Tab|null} existingTab - Optional existing tab to reuse
   * @returns {Browser} The created browser element
   */
  #createBrowserElement(data, currentTab, existingTab = null) {
    const url = data.url;
    const newTabOptions = this.#createTabOptions(currentTab, data);
    const newUUID = gZenUIManager.generateUuidv4();

    currentTab._selected = true;
    const newTab =
      existingTab ??
      gBrowser.addTab(Services.io.newURI(url).spec, newTabOptions);

    this.#configureNewTab(newTab, currentTab, newUUID);
    this.#registerGlance(newTab, currentTab, newUUID);

    gBrowser.selectedTab = newTab;
    return this.#currentBrowser;
  }

  /**
   * Create tab options for a new glance tab
   *
   * @param {Tab} currentTab - The current tab
   * @param {object} data - Glance data for the new tab
   * @returns {object} Tab options
   */
  #createTabOptions(currentTab, data) {
    return {
      userContextId: currentTab.getAttribute("usercontextid") || "",
      skipBackgroundNotify: true,
      insertTab: true,
      skipLoad: false,
      skipAnimation: true,
      ownerTab: currentTab,
      triggeringPrincipal: data.triggeringPrincipal,
    };
  }

  /**
   * Configure a new tab for glance usage
   *
   * @param {Tab} newTab - The new tab to configure
   * @param {Tab} currentTab - The current tab
   * @param {string} glanceId - The glance ID
   */
  #configureNewTab(newTab, currentTab, glanceId) {
    if (currentTab.hasAttribute("zenDefaultUserContextId")) {
      newTab.setAttribute("zenDefaultUserContextId", true);
    }

    currentTab.querySelector(".tab-content").appendChild(newTab);
    newTab.setAttribute("zen-glance-tab", true);
    newTab.setAttribute("glance-id", glanceId);
    currentTab.setAttribute("glance-id", glanceId);
  }

  /**
   * Register a new glance in the glances map
   *
   * @param {Tab} newTab - The new tab
   * @param {Tab} currentTab - The current tab
   * @param {string} glanceId - The glance ID
   */
  #registerGlance(newTab, currentTab, glanceId) {
    this.#glances.set(glanceId, {
      tab: newTab,
      parentTab: currentTab,
      browser: newTab.linkedBrowser,
    });
    this.#currentGlanceID = glanceId;
  }

  /**
   * Fill overlay references from a browser element
   *
   * @param {Browser} browser - The browser element
   */
  fillOverlay(browser) {
    this.overlay = browser.closest(".browserSidebarContainer");
    this.browserWrapper = browser.closest(".browserContainer");
    this.contentWrapper = browser.closest(".browserStack");
  }

  /**
   * Create new overlay buttons with animation
   *
   * @returns {DocumentFragment} The cloned button template
   */
  #createNewOverlayButtons() {
    const template = document.getElementById("zen-glance-sidebar-template");
    const newButtons = template.content.cloneNode(true);
    const container = newButtons.querySelector(".zen-glance-sidebar-container");

    this.#animateOverlayButtons(container);
    return newButtons;
  }

  /**
   * Animate the overlay buttons entrance
   *
   * @param {Element} container - The button container
   */
  #animateOverlayButtons(container) {
    container.style.opacity = 0;

    const xOffset = gZenVerticalTabsManager._prefsRightSide ? 20 : -20;

    gZenUIManager.motion.animate(
      container,
      {
        opacity: [0, 1],
        x: [xOffset, 0],
      },
      {
        duration: 0.2,
        type: "spring",
        delay: this.#GLANCE_ANIMATION_DURATION / 1000 - 0.2,
        bounce: 0,
      }
    );
  }

  /**
   * Get element preview data as a data URL
   *
   * @param {object} data - Glance data
   * @returns {Promise<string|null>} Promise resolving to data URL or null
   * if not available
   */
  async #getElementPreviewData(data) {
    // Make the rect relative to the tabpanels. We dont do it directly on the
    // content process since it does not take into account scroll. This way, we can
    // be sure that the coordinates are correct.
    const tabPanelsRect = gBrowser.tabpanels.getBoundingClientRect();
    const rect = new DOMRect(
      data.clientX + tabPanelsRect.left,
      data.clientY + tabPanelsRect.top,
      data.width,
      data.height
    );
    return await this.#imageBitmapToObjectURL(
      await window.browsingContext.currentWindowGlobal.drawSnapshot(
        rect,
        1,
        "transparent",
        undefined
      )
    );
  }

  /**
   * Set the last link click data
   *
   * @param {object} data - The link click data
   */
  set lastLinkClickData(data) {
    this.#lastLinkClickData = data;
  }

  /**
   * Get the last link click data
   *
   * @returns {object} The last link click data
   */
  get lastLinkClickData() {
    return this.#lastLinkClickData;
  }

  /**
   * Open a glance overlay with the specified data
   *
   * @param {object} data - Glance data including URL, position, and dimensions
   * @param {Tab} existingTab - Optional existing tab to reuse
   * @param {Tab} ownerTab - The tab that owns this glance
   */
  openGlance(data, existingTab = null, ownerTab = null) {
    if (this.#currentBrowser) {
      return Promise.resolve(this.#currentTab);
    }

    if (gBrowser.selectedTab === this.#currentParentTab) {
      gBrowser.selectedTab = this.#currentTab;
      return Promise.resolve(this.#currentTab);
    }

    if (!data.height || !data.width) {
      data = {
        ...data,
        ...this.lastLinkClickData,
      };
    }

    this.#setAnimationState(true);
    const currentTab = ownerTab ?? gBrowser.selectedTab;
    const browserElement = this.#createBrowserElement(
      data,
      currentTab,
      existingTab
    );

    this.fillOverlay(browserElement);
    this.overlay.classList.add("zen-glance-overlay");

    return this.#animateGlanceOpening(data, browserElement);
  }

  /**
   * Set animation state flags
   *
   * @param {boolean} isAnimating - Whether animations are active
   */
  #setAnimationState(isAnimating) {
    this.animatingOpen = isAnimating;
    this._animating = isAnimating;
  }

  /**
   * Animate the glance opening process
   *
   * @param {object} data - Glance data
   * @param {Browser} browserElement - The browser element
   * @returns {Promise<Tab>} Promise that resolves to the glance tab
   */
  #animateGlanceOpening(data, browserElement) {
    this.#prepareGlanceAnimation(data);
    // FIXME(cheffy): We *must* have the call back async (at least,
    // until a better solution is found). If we do it inside the requestAnimationFrame,
    // we see flashing and if we do it directly, the animation does not play at all.
    // eslint-disable-next-line no-async-promise-executor
    return new Promise(async resolve => {
      // Recalculate location. When opening from pinned tabs,
      // view splitter doesn't catch if the tab is a glance tab or not.
      gZenViewSplitter.onLocationChange(browserElement);
      if (data.width && data.height) {
        // It is guaranteed that we will animate this opacity later on
        // when we start animating the glance.
        this.contentWrapper.style.opacity = 0;
        data.elementData = await this.#getElementPreviewData(data);
      }
      this.#glances.get(this.#currentGlanceID).elementData = data.elementData;
      this.#executeGlanceAnimation(data, browserElement, resolve);
    });
  }

  /**
   * Prepare the glance for animation
   *
   * @param {object} data - Glance data
   */
  #prepareGlanceAnimation(data) {
    this.quickOpenGlance();
    const newButtons = this.#createNewOverlayButtons();
    this.browserWrapper.appendChild(newButtons);

    this.#setupGlancePositioning(data);
  }

  /**
   * Animate the parent background
   */
  #animateParentBackground() {
    const parentSidebarContainer = this.#currentParentTab.linkedBrowser.closest(
      ".browserSidebarContainer"
    );

    gZenUIManager.motion.animate(
      parentSidebarContainer,
      {
        scale: [1, GLANCE_BACKGROUND_SCALE],
        opacity: [1, 0.3],
      },
      {
        duration: this.#GLANCE_ANIMATION_DURATION / 1000,
        type: "spring",
        bounce: 0.2,
      }
    );
  }

  /**
   * Set up glance positioning
   *
   * @param {object} data - Glance data with position and dimensions
   */
  #setupGlancePositioning(data) {
    const { clientX, clientY, width, height } = data;
    // eslint-disable-next-line no-shadow
    const top = clientY + height / 2;
    const left = clientX + width / 2;

    this.overlay.removeAttribute("fade-out");
    this.browserWrapper.setAttribute("animate", true);

    this.#storeOriginalPosition({ top, left, width, height });
    this.overlay.style.overflow = "visible";
  }

  /**
   * Store the original position for later restoration
   *
   * @param {object} position - The original position and dimensions of the glance
   */
  #storeOriginalPosition(position) {
    this.#glances.get(this.#currentGlanceID).originalPosition = position;
  }

  #createGlancePreviewElement(src) {
    const imageDataElement = document.createXULElement("image");
    imageDataElement.setAttribute("src", src);

    // eslint-disable-next-line no-shadow
    const parent = document.createElement("div");
    parent.classList.add("zen-glance-element-preview");
    parent.appendChild(imageDataElement);
    return parent;
  }

  /**
   * Handle element preview if provided
   *
   * @param {object} data - Glance data
   * @returns {Element|null} The preview element or null
   */
  #handleElementPreview(data) {
    if (!data.elementData) {
      return null;
    }

    const imageDataElement = this.#createGlancePreviewElement(data.elementData);
    this.browserWrapper.prepend(imageDataElement);
    this.#glances.get(this.#currentGlanceID).elementImageData =
      data.elementData;

    return imageDataElement;
  }

  /**
   * Execute the main glance animation
   *
   * @param {object} data - Glance data
   * @param {Browser} browserElement - The browser element
   * @param {Function} resolve - Promise resolve function
   */
  #executeGlanceAnimation(data, browserElement, resolve) {
    const imageDataElement = this.#handleElementPreview(data);

    // Create the curved animation sequence. The transform origin is handled
    // separately (for example via CSS on the wrapper).
    const arcSequence = this.#createGlanceArcSequence(
      data,
      "opening",
      imageDataElement
    );

    // Only animate if there is element data, so we can apply a
    // nice fade-in effect to the content. But if it doesn't exist,
    // we just fall back to always showing the browser directly.
    if (data.elementData) {
      gZenUIManager
        .elementAnimate(
          this.contentWrapper,
          { opacity: [0, 1] },
          {
            duration: this.#GLANCE_ANIMATION_DURATION / 4,
            easing: "ease-in-out",
          }
        )
        .then(() => {
          this.contentWrapper.style.opacity = "";
        });
    }

    this.#animateParentBackground();
    let activeValue = browserElement.zenModeActive;
    let shouldDeactivateDocShell = Services.prefs.getBoolPref(
      "zen.glance.deactivate-docshell-during-animation"
    );
    if (shouldDeactivateDocShell) {
      browserElement.zenModeActive = false;
      browserElement.docShellIsActive = false;
    }
    gZenUIManager
      .elementAnimate(this.browserWrapper, arcSequence, {
        duration: gZenUIManager.testingEnabled
          ? 0
          : this.#GLANCE_ANIMATION_DURATION,
        easing: "ease-in-out",
      })
      .then(() => {
        if (shouldDeactivateDocShell) {
          browserElement.zenModeActive = activeValue;
          browserElement.docShellIsActive = true;
        }
        this.#finalizeGlanceOpening(imageDataElement, browserElement, resolve);
      });
  }

  /**
   * Create arc animation sequence for glance animations
   *
   * @param {object} data - Glance data with position and dimensions
   * @param {string} direction - 'opening' or 'closing'
   * @param {Element|null} imageDataElement - The image data element for preview (optional)
   * @returns {object} Animation sequence object
   */
  #createGlanceArcSequence(data, direction, imageDataElement = null) {
    let { clientX, clientY, width, height } = data;
    if (imageDataElement?.parentElement) {
      // Since we are animating scale transforms on the wrapper, we need to
      // adjust the width/height to match the scaled size of the element preview,
      // so the image preview properly matches the size of the animating browser
      // during the animation.
      // For example:
      // +-- wrapper --------------------------+
      // |                                     |
      // | +--- element preview -------------+ |
      // | |                                 | |
      // | +---------------------------------+ |
      // |                                     |
      // +-------------------------------------+
      // We are scaling the wrapper while having only the element preview size
      // in mind, so we need to adjust the width/height to match the size of the element preview
      const rect = imageDataElement.getBoundingClientRect();
      const aspectRatio = width / height;
      const heightRatio = rect.height / (rect.width / aspectRatio);
      const originalHeight = height;
      if (heightRatio > 1) {
        height *= heightRatio;
        clientY -= (height - originalHeight) / 2;
      }
    }

    // Calculate start and end positions based on direction
    let startPosition, endPosition;

    const tabPanelsRect = window.windowUtils.getBoundsWithoutFlushing(
      gBrowser.tabpanels
    );

    const widthPercent = 0.8;
    if (direction === "opening") {
      startPosition = {
        x: clientX + width / 2,
        y: clientY + height / 2,
        width,
        height,
      };
      endPosition = {
        x: tabPanelsRect.width / 2,
        y: tabPanelsRect.height / 2,
        width: tabPanelsRect.width * widthPercent,
        height: tabPanelsRect.height,
      };
    } else {
      // closing
      startPosition = {
        x: tabPanelsRect.width / 2,
        y: tabPanelsRect.height / 2,
        width: tabPanelsRect.width * widthPercent,
        height: tabPanelsRect.height,
      };
      endPosition = {
        x: Math.floor(clientX + width / 2),
        y: Math.floor(clientY + height / 2),
        width,
        height,
      };
    }

    // Reference size used as the scale(1, 1) baseline — this matches the
    // wrapper's natural CSS size (80% x 100% of the tab panels) so the
    // animation can run entirely on the compositor via transform.
    const refWidth = tabPanelsRect.width * widthPercent;
    const refHeight = tabPanelsRect.height;

    // Calculate distance and arc parameters
    const distance = this.#calculateDistance(startPosition, endPosition);
    const { arcHeight, shouldArcDownward } = this.#calculateOptimalArc(
      startPosition,
      endPosition,
      distance
    );

    const sequence = {
      x: [],
      y: [],
      scaleY: [],
      scaleX: [],
    };

    const steps = this.#ARC_CONFIG.ARC_STEPS;
    const arcDirection = shouldArcDownward ? 1 : -1;

    function easeOutBack(x) {
      const c1 = 0.4;
      const c3 = c1 + 1;
      return 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2;
    }

    function easeOutCubic(t) {
      return 1 - Math.pow(1 - t, 6);
    }

    // First, create the main animation steps
    for (let i = 0; i <= steps; i++) {
      const progress = i / steps;
      const eased =
        direction === "opening"
          ? easeOutBack(progress)
          : easeOutCubic(progress);

      // Calculate size interpolation
      const currentWidth =
        startPosition.width + (endPosition.width - startPosition.width) * eased;
      const currentHeight =
        startPosition.height +
        (endPosition.height - startPosition.height) * eased;
      const scaleX = currentWidth / refWidth;
      const scaleY = currentHeight / refHeight;

      // Calculate position on arc
      const distanceX = endPosition.x - startPosition.x;
      const distanceY = endPosition.y - startPosition.y;

      const x = startPosition.x + distanceX * eased;
      const y =
        startPosition.y +
        distanceY * eased +
        arcDirection * arcHeight * (1 - (2 * eased - 1) ** 2);

      let translateX = x - currentWidth / 2;
      let translateY = y - currentHeight / 2;
      sequence.x.push(translateX);
      sequence.y.push(translateY);
      sequence.scaleX.push(scaleX);
      sequence.scaleY.push(scaleY);
    }

    return sequence;
  }

  /**
   * Calculate distance between two positions
   *
   * @param {object} start - Start position
   * @param {object} end - End position
   * @returns {number} Distance
   */
  #calculateDistance(start, end) {
    const distanceX = end.x - start.x;
    const distanceY = end.y - start.y;
    return Math.sqrt(distanceX * distanceX + distanceY * distanceY);
  }

  /**
   * Calculate optimal arc parameters
   *
   * @param {object} startPosition - Start position
   * @param {object} endPosition - End position
   * @param {number} distance - Distance between positions
   * @returns {object} Arc parameters
   */
  #calculateOptimalArc(startPosition, endPosition, distance) {
    // Calculate available space for the arc
    const availableTopSpace = Math.min(startPosition.y, endPosition.y);
    const viewportHeight = window.innerHeight;
    const availableBottomSpace =
      viewportHeight - Math.max(startPosition.y, endPosition.y);

    // Determine if we should arc downward or upward based on available space
    const shouldArcDownward = availableBottomSpace > availableTopSpace;

    // Use the space in the direction we're arcing
    const availableSpace = shouldArcDownward
      ? availableBottomSpace
      : availableTopSpace;

    // Limit arc height to a percentage of the available space
    const arcHeight = Math.min(
      distance * this.#ARC_CONFIG.ARC_HEIGHT_RATIO,
      this.#ARC_CONFIG.MAX_ARC_HEIGHT,
      availableSpace * 0.6
    );

    return { arcHeight, shouldArcDownward };
  }

  /**
   * Finalize the glance opening process
   *
   * @param {Element|null} imageDataElement - The preview element
   * @param {Browser} browserElement - The browser element
   * @param {Function} resolve - Promise resolve function
   */
  #finalizeGlanceOpening(imageDataElement, browserElement, resolve) {
    if (imageDataElement) {
      imageDataElement.remove();
    }

    // Batch all style/attribute writes together to avoid interleaved
    // read/write layout thrashing.
    this.browserWrapper.style.height = "100%";
    this.browserWrapper.style.width = "80%";
    this.browserWrapper.removeAttribute("animate");
    this.browserWrapper.setAttribute("has-finished-animation", true);
    this.overlay.style.removeProperty("overflow");

    gBrowser.tabContainer._invalidateCachedTabs();
    this.#setAnimationState(false);
    this.#currentTab.dispatchEvent(new Event("GlanceOpen", { bubbles: true }));
    resolve(this.#currentTab);
  }

  /**
   * Clear container styles while preserving inset
   *
   * @param {Element} container - The container element
   */
  #clearContainerStyles(container) {
    const inset = container.style.inset;
    container.removeAttribute("style");
    container.style.inset = inset;
  }

  /**
   * Close the current glance
   *
   * @param {object} options - Close options
   * @param {boolean} options.noAnimation - Skip animation
   * @param {boolean} options.onTabClose - Called during tab close
   * @param {string} options.setNewID - Set new glance ID
   * @param {boolean} options.hasFocused - Has focus confirmation
   * @param {boolean} options.skipPermitUnload - Skip unload permission check
   * @returns {Promise|undefined} Promise if animated, undefined if immediate
   */
  closeGlance({
    noAnimation = false,
    onTabClose = false,
    setNewID = null,
    hasFocused = false,
    skipPermitUnload = false,
  } = {}) {
    if (!this.#canCloseGlance(onTabClose)) {
      return;
    }

    if (!skipPermitUnload && !this.#checkPermitUnload()) {
      return;
    }

    const browserSidebarContainer =
      this.#currentParentTab?.linkedBrowser?.closest(
        ".browserSidebarContainer"
      );
    const sidebarButtons = this.browserWrapper.querySelector(
      ".zen-glance-sidebar-container"
    );

    if (
      this.#handleConfirmationTimeout(onTabClose, hasFocused, sidebarButtons)
    ) {
      return;
    }

    this.browserWrapper.removeAttribute("has-finished-animation");

    if (noAnimation) {
      this.#clearContainerStyles(browserSidebarContainer);
      this.quickCloseGlance({ closeCurrentTab: false });
      return;
    }

    return this.#animateGlanceClosing(
      onTabClose,
      browserSidebarContainer,
      sidebarButtons,
      setNewID
    );
  }

  /**
   * Check if glance can be closed
   *
   * @param {boolean} onTabClose - Whether this is called during tab close
   * @returns {boolean} True if can close
   */
  #canCloseGlance(onTabClose) {
    return !(
      (this._animating && !onTabClose) ||
      !this.#currentBrowser ||
      (this.animatingOpen && !onTabClose) ||
      this.#duringOpening
    );
  }

  /**
   * Check if unload is permitted
   *
   * @returns {boolean} True if unload is permitted
   */
  #checkPermitUnload() {
    const { permitUnload } = this.#currentBrowser.permitUnload();
    return permitUnload;
  }

  /**
   * Handle confirmation timeout for focused close
   *
   * @param {boolean} onTabClose - Whether this is called during tab close
   * @param {boolean} hasFocused - Has focus confirmation
   * @param {Element} sidebarButtons - The sidebar buttons element
   * @returns {boolean} True if should return early
   */
  #handleConfirmationTimeout(onTabClose, hasFocused, sidebarButtons) {
    if (
      onTabClose &&
      hasFocused &&
      !this.#confirmationTimeout &&
      sidebarButtons
    ) {
      const cancelButton = sidebarButtons.querySelector(
        ".zen-glance-sidebar-close"
      );
      cancelButton.setAttribute("waitconfirmation", true);
      this.#confirmationTimeout = setTimeout(() => {
        cancelButton.removeAttribute("waitconfirmation");
        this.#confirmationTimeout = null;
      }, 3000);
      return true;
    }
    return false;
  }

  /**
   * Animate the glance closing process
   *
   * @param {boolean} onTabClose - Whether this is called during tab close
   * @param {Element} browserSidebarContainer - The sidebar container
   * @param {Element} sidebarButtons - The sidebar buttons
   * @param {string} setNewID - New glance ID to set
   */
  #animateGlanceClosing(
    onTabClose,
    browserSidebarContainer,
    sidebarButtons,
    setNewID
  ) {
    if (this.closingGlance) {
      return;
    }

    this.closingGlance = true;
    this._animating = true;

    gBrowser.moveTabAfter(this.#currentTab, this.#currentParentTab);

    if (onTabClose && gBrowser.tabs.length === 1) {
      BrowserCommands.openTab();
      return;
    }

    this.#prepareGlanceForClosing();
    this.#animateSidebarButtons(sidebarButtons);
    this.#animateParentBackgroundClose(browserSidebarContainer);

    return this.#executeClosingAnimation(setNewID, onTabClose);
  }

  /**
   * Prepare glance for closing
   */
  #prepareGlanceForClosing() {
    // Critical: This line must not be touched - it works for unknown reasons
    this.#currentTab.style.display = "none";
    this.overlay.setAttribute("fade-out", true);
    this.overlay.style.pointerEvents = "none";
    this.quickCloseGlance({ justAnimateParent: true, clearID: false });
  }

  /**
   * Animate sidebar buttons out
   *
   * @param {Element} sidebarButtons - The sidebar buttons element
   */
  #animateSidebarButtons(sidebarButtons) {
    if (sidebarButtons) {
      gZenUIManager.motion
        .animate(
          sidebarButtons,
          { opacity: [1, 0] },
          {
            duration: 0.2,
            type: "spring",
            bounce: this.#GLANCE_ANIMATION_DURATION / 1000 - 0.1,
          }
        )
        .then(() => {
          sidebarButtons.remove();
        });
    }
  }

  async #imageBitmapToObjectURL(imageBitmap) {
    // OffscreenCanvas + convertToBlob avoids the synchronous PNG re-encode
    // and base64 string copy that toDataURL performs on the main thread.
    // Callers must release the URL via #deleteGlance when the glance entry
    // is removed so the blob can be freed.
    const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height);
    const ctx = canvas.getContext("2d");
    ctx.drawImage(imageBitmap, 0, 0);
    const blob = await canvas.convertToBlob({ type: "image/png" });
    imageBitmap.close();
    return URL.createObjectURL(blob);
  }

  #deleteGlance(glanceID) {
    const entry = this.#glances.get(glanceID);
    if (!entry) {
      return;
    }
    this.#glances.delete(glanceID);
    const url = entry.elementData ?? entry.elementImageData;
    if (typeof url === "string") {
      URL.revokeObjectURL(url);
    }
  }

  /**
   * Animate parent background restoration
   *
   * @param {Element} browserSidebarContainer - The sidebar container
   */
  #animateParentBackgroundClose(browserSidebarContainer) {
    gZenUIManager.motion
      .animate(
        browserSidebarContainer,
        {
          scale: [GLANCE_BACKGROUND_SCALE, 1],
          opacity: [0.3, 1],
        },
        {
          duration: this.#GLANCE_ANIMATION_DURATION / 1000 / 1.5,
          type: "spring",
          bounce: 0,
        }
      )
      .then(() => {
        this.#clearContainerStyles(browserSidebarContainer);
      });

    this.browserWrapper.style.opacity = 1;
  }

  /**
   * Execute the main closing animation
   *
   * @param {string} setNewID - New glance ID to set
   * @param {boolean} onTabClose - Whether this is called during tab close
   * @returns {Promise} Promise that resolves when complete
   */
  #executeClosingAnimation(setNewID, onTabClose) {
    return new Promise(resolve => {
      const originalPosition = this.#glances.get(
        this.#currentGlanceID
      ).originalPosition;
      const elementImageData = this.#glances.get(
        this.#currentGlanceID
      ).elementImageData;

      const imageDataElement = this.#addElementPreview(elementImageData);

      // Create curved closing animation sequence
      const closingData =
        this.#createClosingDataFromOriginalPosition(originalPosition);
      const arcSequence = this.#createGlanceArcSequence(
        closingData,
        "closing",
        imageDataElement
      );

      // Batch style writes before starting animation to avoid layout thrashing
      this.browserWrapper.style.width = "";
      this.browserWrapper.style.height = "";

      gZenUIManager
        .elementAnimate(this.browserWrapper, arcSequence, {
          duration: this.#GLANCE_ANIMATION_DURATION,
          easing: "ease-out",
        })
        .then(() => {
          // Remove element preview after closing animation
          const elementPreview = this.browserWrapper.querySelector(
            ".zen-glance-element-preview"
          );
          if (elementPreview) {
            elementPreview.remove();
          }
          this.#finalizeGlanceClosing(setNewID, resolve, onTabClose);
        });
    });
  }

  /**
   * Create closing data from original position for arc animation
   *
   * @param {object} originalPosition - Original position object
   * @returns {object} Closing data object
   */
  #createClosingDataFromOriginalPosition(originalPosition) {
    // Parse the original position values
    // eslint-disable-next-line no-shadow
    const top = parseFloat(originalPosition.top) || 0;
    const left = parseFloat(originalPosition.left) || 0;
    const width = parseFloat(originalPosition.width) || 0;
    const height = parseFloat(originalPosition.height) || 0;

    return {
      clientX: left - width / 2,
      clientY: top - height / 2,
      width,
      height,
    };
  }

  /**
   * Add element preview if available, used for the closing animation
   *
   * @param {string} elementImageData - The element image data
   */
  #addElementPreview(elementImageData) {
    if (elementImageData) {
      const imageDataElement =
        this.#createGlancePreviewElement(elementImageData);
      this.browserWrapper.prepend(imageDataElement);
      return imageDataElement;
    }
  }

  /**
   * Finalize the glance closing process
   *
   * @param {string} setNewID - New glance ID to set
   * @param {Function} resolve - Promise resolve function
   * @param {boolean} onTabClose - Whether this is called during tab close
   */
  #finalizeGlanceClosing(setNewID, resolve, onTabClose) {
    this.browserWrapper.removeAttribute("animate");

    if (!this.#currentParentTab) {
      this.closingGlance = false;
      return;
    }

    if (!onTabClose) {
      this.quickCloseGlance({ clearID: false });
    }
    this.overlay.style.display = "none";
    this.overlay.removeAttribute("fade-out");
    this.browserWrapper.removeAttribute("animate");

    const lastCurrentTab = this.#currentTab;
    this.#cleanupGlanceElements(lastCurrentTab);
    this.#resetGlanceState(setNewID);

    this.#setAnimationState(false);
    this.closingGlance = false;

    if (this.#currentGlanceID) {
      this.quickOpenGlance();
    }

    resolve();
  }

  /**
   * Clean up glance DOM elements
   *
   * @param {Tab} lastCurrentTab - The tab being closed
   */
  #cleanupGlanceElements(lastCurrentTab) {
    this.overlay.classList.remove("zen-glance-overlay");
    gBrowser
      ._getSwitcher()
      .setTabStateNoAction(
        lastCurrentTab,
        gBrowser.AsyncTabSwitcher.STATE_UNLOADED
      );

    if (!this.#currentParentTab.selected) {
      this.#currentParentTab._visuallySelected = false;
    }

    if (gBrowser.selectedTab === lastCurrentTab) {
      gBrowser.selectedTab = this.#currentParentTab;
    }

    if (
      this.#currentParentTab.linkedBrowser &&
      !this.#currentParentTab.hasAttribute("split-view")
    ) {
      this.#currentParentTab.linkedBrowser.zenModeActive = false;
    }

    // Reset overlay references
    this.browserWrapper = null;
    this.overlay = null;
    this.contentWrapper = null;

    lastCurrentTab.removeAttribute("zen-glance-tab");

    this.#ignoreClose = true;
    lastCurrentTab.dispatchEvent(new Event("GlanceClose", { bubbles: true }));
    gBrowser.removeTab(lastCurrentTab, {
      animate: true,
      skipPermitUnload: true,
    });
    gBrowser.tabContainer._invalidateCachedTabs();
  }

  /**
   * Reset glance state
   *
   * @param {string} setNewID - New glance ID to set
   */
  #resetGlanceState(setNewID) {
    this.#currentParentTab.removeAttribute("glance-id");
    this.#deleteGlance(this.#currentGlanceID);
    this.#currentGlanceID = setNewID;
    this.#duringOpening = false;
  }

  /**
   * Quickly open glance without animation
   */
  quickOpenGlance() {
    if (!this.#currentBrowser || this.#duringOpening) {
      return;
    }

    this.#duringOpening = true;
    // IMPORTANT: #setGlanceStates() must be called before #configureGlanceElements()
    // to ensure that the glance state is fully set up before configuring the DOM elements.
    // This order is required to avoid timing/state issues. Do not reorder without understanding the dependencies.
    this.#setGlanceStates();
    this.#configureGlanceElements();
    this.#duringOpening = false;
  }

  /**
   * Configure glance DOM elements
   */
  #configureGlanceElements() {
    const parentBrowserContainer = this.#currentParentTab.linkedBrowser.closest(
      ".browserSidebarContainer"
    );

    parentBrowserContainer.classList.add("zen-glance-background");
    parentBrowserContainer.classList.remove("zen-glance-overlay");
    parentBrowserContainer.classList.add("deck-selected");

    this.overlay.classList.add("deck-selected");
    this.overlay.classList.add("zen-glance-overlay");
  }

  /**
   * Set glance browser and tab states
   */
  #setGlanceStates() {
    this.#currentParentTab.linkedBrowser.zenModeActive = true;
    this.#currentParentTab.linkedBrowser.docShellIsActive = true;
    this.#currentBrowser.zenModeActive = true;
    this.#currentBrowser.docShellIsActive = true;
    this.#currentBrowser.setAttribute("zen-glance-selected", true);
    this.fillOverlay(this.#currentBrowser);
    this.#currentParentTab._visuallySelected = true;
  }

  /**
   * Quickly close glance without animation
   *
   * @param {object} options - Close options
   * @param {boolean} options.closeCurrentTab - Close current tab
   * @param {boolean} options.closeParentTab - Close parent tab
   * @param {boolean} options.justAnimateParent - Only animate parent
   * @param {boolean} options.clearID - Clear current glance ID
   */
  quickCloseGlance({
    closeCurrentTab = true,
    closeParentTab = true,
    justAnimateParent = false,
    clearID = true,
  } = {}) {
    const parentHasBrowser = !!this.#currentParentTab.linkedBrowser;
    const browserContainer = this.#currentParentTab.linkedBrowser.closest(
      ".browserSidebarContainer"
    );

    this.#removeParentBackground(parentHasBrowser, browserContainer);

    if (!justAnimateParent && this.overlay) {
      this.#resetGlanceStates(
        closeCurrentTab,
        closeParentTab,
        parentHasBrowser,
        browserContainer
      );
    }

    if (clearID) {
      this.#currentGlanceID = null;
    }
  }

  /**
   * Remove parent background styling
   *
   * @param {boolean} parentHasBrowser - Whether parent has browser
   * @param {Element} browserContainer - The browser container
   */
  #removeParentBackground(parentHasBrowser, browserContainer) {
    if (parentHasBrowser) {
      browserContainer.classList.remove("zen-glance-background");
    }
  }

  /**
   * Reset glance states
   *
   * @param {boolean} closeCurrentTab - Whether to close current tab
   * @param {boolean} closeParentTab - Whether to close parent tab
   * @param {boolean} parentHasBrowser - Whether parent has browser
   * @param {Element} browserContainer - The browser container
   */
  #resetGlanceStates(
    closeCurrentTab,
    closeParentTab,
    parentHasBrowser,
    browserContainer
  ) {
    if (
      parentHasBrowser &&
      !this.#currentParentTab.hasAttribute("split-view")
    ) {
      if (closeParentTab) {
        browserContainer.classList.remove("deck-selected");
      }
      this.#currentParentTab.linkedBrowser.zenModeActive = false;
    }

    this.#currentBrowser.zenModeActive = false;

    if (closeParentTab && parentHasBrowser) {
      this.#currentParentTab.linkedBrowser.docShellIsActive = false;
    }

    if (closeCurrentTab) {
      this.#currentBrowser.docShellIsActive = false;
      this.overlay.classList.remove("deck-selected");
      this.#currentTab._selected = false;
    }

    if (!this.#currentParentTab._visuallySelected && closeParentTab) {
      this.#currentParentTab._visuallySelected = false;
    }

    this.#currentBrowser.removeAttribute("zen-glance-selected");
  }

  /**
   * Open glance on location change if not animating
   *
   * @param {Tab} prevTab - The previous tab
   */
  #onLocationChangeOpenGlance(prevTab) {
    if (!this.animatingOpen) {
      this.quickOpenGlance();
      if (prevTab && prevTab.linkedBrowser) {
        prevTab.linkedBrowser.docShellIsActive = false;
        prevTab.linkedBrowser
          .closest(".browserSidebarContainer")
          .classList.remove("deck-selected");
      }
    }
  }

  /**
   * Handle location change events
   * Note: Must be sync to avoid timing issues
   *
   * @param {Event} event - The location change event
   */
  onLocationChange(event) {
    const tab = event.target;
    const prevTab = event.detail.previousTab;

    if (this.animatingFullOpen || this.closingGlance) {
      return;
    }

    if (this.#duringOpening || !tab.hasAttribute("glance-id")) {
      if (this.#currentGlanceID && !this.#duringOpening) {
        this.quickCloseGlance();
      }
      return;
    }

    if (
      this.#currentGlanceID &&
      this.#currentGlanceID !== tab.getAttribute("glance-id")
    ) {
      this.quickCloseGlance();
    }

    this.#currentGlanceID = tab.getAttribute("glance-id");
    if (gBrowser.selectedTab === this.#currentTab) {
      this.#onLocationChangeOpenGlance(prevTab);
      return;
    }
    this.#currentGlanceID = null;
  }

  /**
   * Handle tab close events
   *
   * @param {Event} event - The tab close event
   */
  onTabClose(event) {
    if (event.target === this.#currentParentTab) {
      this.closeGlance({ onTabClose: true });
    }
  }

  /**
   * Manage tab close for glance tabs
   *
   * @param {Tab} tab - The tab being closed
   * @returns {boolean} Whether to continue with tab close
   */
  manageTabClose(tab) {
    if (!tab.hasAttribute("glance-id")) {
      return false;
    }

    const oldGlanceID = this.#currentGlanceID;
    const newGlanceID = tab.getAttribute("glance-id");
    this.#currentGlanceID = newGlanceID;
    const isDifferent = newGlanceID !== oldGlanceID;

    if (this.#ignoreClose) {
      this.#ignoreClose = false;
      return false;
    }

    this.closeGlance({
      onTabClose: true,
      setNewID: isDifferent ? oldGlanceID : null,
    });

    // Only continue tab close if we are not on the currently selected tab
    return !isDifferent;
  }

  /**
   * Check if two tabs have different domains
   *
   * @param {Tab} tab1 - First tab
   * @param {nsIURI} url2 - Second URL
   * @returns {boolean} True if domains differ
   */
  tabDomainsDiffer(tab1, url2) {
    try {
      if (!tab1) {
        return true;
      }

      const url1 = tab1.linkedBrowser.currentURI.spec;
      if (url1.startsWith("about:")) {
        return true;
      }

      // Only glance up links that are http(s) or file
      // https://github.com/zen-browser/desktop/issues/7173
      const url2Spec = url2.spec;
      if (!this.#isValidGlanceUrl(url2Spec)) {
        return false;
      }

      return Services.io.newURI(url1).host !== url2.host;
    } catch {
      return true;
    }
  }

  /**
   * Check if URL is valid for glance
   *
   * @param {string} urlSpec - The URL spec
   * @returns {boolean} True if valid
   */
  #isValidGlanceUrl(urlSpec) {
    return (
      urlSpec.startsWith("http") ||
      urlSpec.startsWith("https") ||
      urlSpec.startsWith("file")
    );
  }

  /**
   * Check if a tab should be opened in glance
   *
   * @param {Tab} tab - The tab to check
   * @param {nsIURI} uri - The URI to check
   * @returns {boolean} True if should open in glance
   */
  shouldOpenTabInGlance(tab, uri) {
    const owner = tab.owner;

    return (
      owner &&
      owner.pinned &&
      this._lazyPref.SHOULD_OPEN_EXTERNAL_TABS_IN_GLANCE &&
      owner.linkedBrowser?.browsingContext?.isAppTab &&
      this.tabDomainsDiffer(owner, uri) &&
      Services.prefs.getBoolPref("zen.glance.enabled", true)
    );
  }

  /**
   * Handle tab open events
   *
   * @param {Browser} browser - The browser element
   * @param {nsIURI} uri - The URI being opened
   */
  onTabOpen(browser, uri) {
    const tab = gBrowser.getTabForBrowser(browser);
    if (!tab) {
      return;
    }

    try {
      if (this.shouldOpenTabInGlance(tab, uri)) {
        this.#openGlanceForTab(tab);
      }
    } catch (e) {
      console.error("Error opening glance for tab:", e);
    }
  }

  /**
   * Open glance for a specific tab
   *
   * @param {Tab} tab - The tab to open glance for
   */
  #openGlanceForTab(tab) {
    this.openGlance(
      {
        url: undefined,
        // No need for triggeringPrincipal here
      },
      tab,
      tab.owner
    );
  }

  /**
   * Finish opening glance and clean up
   */
  finishOpeningGlance() {
    gBrowser.tabContainer._invalidateCachedTabs();
    gZenWorkspaces.updateTabsContainers();
    this.overlay.classList.remove("zen-glance-overlay");
    this.#clearContainerStyles(this.browserWrapper);
    this.animatingFullOpen = false;
    const glanceID = this.#currentGlanceID;
    this.closeGlance({ noAnimation: true, skipPermitUnload: true });
    this.#deleteGlance(glanceID);
  }

  /**
   * Fully open glance (convert to regular tab)
   *
   * @param {object} options - Options for full opening
   * @param {boolean} options.forSplit - Whether this is for split view
   */
  async fullyOpenGlance({ forSplit = false } = {}) {
    if (!this.#currentGlanceID || !this.#currentTab) {
      return;
    }

    this.animatingFullOpen = true;
    this.#currentTab.setAttribute("zen-dont-split-glance", true);

    this.#handleZenFolderPinning();
    gBrowser.moveTabAfter(this.#currentTab, this.#currentParentTab);

    this.#prepareTabForFullOpen();

    const sidebarButtons = this.browserWrapper.querySelector(
      ".zen-glance-sidebar-container"
    );
    if (sidebarButtons) {
      sidebarButtons.remove();
    }

    if (forSplit) {
      this.finishOpeningGlance();
      return;
    }

    if (gReduceMotion) {
      gZenViewSplitter.deactivateCurrentSplitView();
      this.finishOpeningGlance();
      return;
    }

    await this.#animateFullOpen();
    this.finishOpeningGlance();
  }

  /**
   * Handle Zen folder pinning if applicable
   */
  #handleZenFolderPinning() {
    const isZenFolder = this.#currentParentTab?.group?.isZenFolder;
    if (
      Services.prefs.getBoolPref("zen.folders.owned-tabs-in-folder") &&
      isZenFolder
    ) {
      gBrowser.pinTab(this.#currentTab);
    }
  }

  /**
   * Prepare tab for full opening
   */
  #prepareTabForFullOpen() {
    this.#currentTab.removeAttribute("zen-glance-tab");
    this.#clearContainerStyles(this.browserWrapper);
    this.#currentTab.removeAttribute("glance-id");
    this.#currentParentTab.removeAttribute("glance-id");
    gBrowser.selectedTab = this.#currentTab;

    this.#currentParentTab.linkedBrowser
      .closest(".browserSidebarContainer")
      .classList.remove("zen-glance-background");
    this.#currentParentTab._visuallySelected = false;
    gBrowser.TabStateFlusher.flush(this.#currentTab.linkedBrowser);
  }

  /**
   * Animate the full opening process
   */
  async #animateFullOpen() {
    // Write styles early to avoid flickering
    this.browserWrapper.style.width = "100%";
    this.browserWrapper.style.height = "100%";

    await gZenUIManager.elementAnimate(
      this.browserWrapper.parentElement,
      {
        scale: [1, 1.005, 1],
      },
      {
        duration: 250,
        easing: "ease-in-out",
      }
    );

    this.browserWrapper.style.scale = "";
    this.browserWrapper.style.opacity = "";
    this.browserWrapper.style.width = "";
    this.browserWrapper.style.height = "";
    gZenViewSplitter.deactivateCurrentSplitView({ removeDeckSelected: true });
  }

  /**
   * Open glance for bookmark activation
   *
   * @param {Event} event - The bookmark click event
   */
  openGlanceForBookmark(event) {
    const activationMethod = Services.prefs.getStringPref(
      "zen.glance.activation-method",
      "ctrl"
    );

    if (!this.#isActivationKeyPressed(event, activationMethod)) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    const data = this.#createGlanceDataFromBookmark(event);
    this.openGlance(data);
  }

  /**
   * Check if the correct activation key is pressed
   *
   * @param {Event} event - The event
   * @param {string} activationMethod - The activation method
   * @returns {boolean} True if key is pressed
   */
  #isActivationKeyPressed(event, activationMethod) {
    const keyMap = {
      ctrl: event.ctrlKey,
      alt: event.altKey,
      shift: event.shiftKey,
      meta: event.metaKey,
    };

    return keyMap[activationMethod] || false;
  }

  /**
   * Create glance data from bookmark event
   *
   * @param {Event} event - The bookmark event
   * @returns {object} Glance data object
   */
  #createGlanceDataFromBookmark(event) {
    const rect = window.windowUtils.getBoundsWithoutFlushing(event.target);
    const tabPanelRect = window.windowUtils.getBoundsWithoutFlushing(
      gBrowser.tabpanels
    );
    // the bookmark is most likely outisde the tabpanel, so we need to give a negative number
    // so it can be corrected later
    // eslint-disable-next-line no-shadow
    let top = rect.top - tabPanelRect.top;
    let left = rect.left - tabPanelRect.left;
    return {
      url: event.target._placesNode.uri,
      clientX: left,
      clientY: top,
      width: rect.width,
      height: rect.height,
      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    };
  }

  /**
   * Get the focused tab based on direction
   *
   * @param {number} aDir - Direction (-1 for parent, 1 for current)
   * @returns {Tab} The focused tab
   */
  getFocusedTab(aDir) {
    return aDir < 0 ? this.#currentParentTab : this.#currentTab;
  }

  /**
   * Split the current glance into a split view
   */
  async splitGlance() {
    if (!this.#currentGlanceID) {
      return;
    }

    const currentTab = this.#currentTab;
    const currentParentTab = this.#currentParentTab;

    this.#handleZenFolderPinningForSplit(currentParentTab);
    await this.fullyOpenGlance({ forSplit: true });

    const isRightSidebar = gZenVerticalTabsManager._prefsRightSide;
    gZenViewSplitter.splitTabs(
      isRightSidebar
        ? [currentTab, currentParentTab]
        : [currentParentTab, currentTab],
      "vsep",
      isRightSidebar ? 0 : 1
    );

    const browserContainer = currentTab.linkedBrowser?.closest(
      ".browserSidebarContainer"
    );
    if (!gReduceMotion && browserContainer) {
      gZenViewSplitter.animateBrowserDrop(browserContainer);
    }
  }

  /**
   * Handle Zen folder pinning for split view
   *
   * @param {Tab} parentTab - The parent tab
   */
  #handleZenFolderPinningForSplit(parentTab) {
    const isZenFolder = parentTab?.group?.isZenFolder;
    if (
      Services.prefs.getBoolPref("zen.folders.owned-tabs-in-folder") &&
      isZenFolder
    ) {
      gBrowser.pinTab(this.#currentTab);
    }
  }

  /**
   * Get the tab or its glance parent
   *
   * @param {Tab} tab - The tab to check
   * @returns {Tab} The tab or its parent
   */
  getTabOrGlanceParent(tab) {
    if (tab?.hasAttribute("glance-id") && this.#glances) {
      const parentTab = this.#glances.get(
        tab.getAttribute("glance-id")
      )?.parentTab;
      if (parentTab) {
        return parentTab;
      }
    }
    return tab;
  }

  /**
   * Get the tab or its glance child
   *
   * @param {Tab} tab - The tab to check
   * @returns {Tab} The tab or its child
   */
  getTabOrGlanceChild(tab) {
    return tab?.glanceTab || tab;
  }

  /**
   * Check if deck should remain selected
   *
   * @param {Element} currentPanel - Current panel
   * @param {Element} oldPanel - Previous panel
   * @returns {boolean} True if deck should remain selected
   */
  shouldShowDeckSelected(currentPanel, oldPanel) {
    const currentBrowser = currentPanel?.querySelector("browser");
    const oldBrowser = oldPanel?.querySelector("browser");

    if (!currentBrowser || !oldBrowser) {
      return false;
    }

    const currentTab = gBrowser.getTabForBrowser(currentBrowser);
    const oldTab = gBrowser.getTabForBrowser(oldBrowser);

    if (!currentTab || !oldTab) {
      return false;
    }

    const currentGlanceID = currentTab.getAttribute("glance-id");
    const oldGlanceID = oldTab.getAttribute("glance-id");

    if (currentGlanceID && oldGlanceID) {
      return (
        currentGlanceID === oldGlanceID &&
        oldPanel.classList.contains("zen-glance-background")
      );
    }

    return false;
  }

  /**
   * Handle search select command
   *
   * @param {string} where - Where to open the search result
   */
  onSearchSelectCommand(where) {
    if (!this.#isGlanceEnabledForSearch()) {
      return;
    }

    if (where !== "tab") {
      return;
    }

    const currentTab = gBrowser.selectedTab;
    const parentTab = currentTab.owner;

    if (!parentTab || parentTab.hasAttribute("glance-id")) {
      return;
    }

    this.#openGlanceForSearch(currentTab, parentTab);
  }

  /**
   * Check if glance is enabled for search
   *
   * @returns {boolean} True if enabled
   */
  #isGlanceEnabledForSearch() {
    return (
      Services.prefs.getBoolPref("zen.glance.enabled", false) &&
      Services.prefs.getBoolPref("zen.glance.enable-contextmenu-search", true)
    );
  }

  /**
   * Open glance for search result
   *
   * @param {Tab} currentTab - Current tab
   * @param {Tab} parentTab - Parent tab
   */
  #openGlanceForSearch(currentTab, parentTab) {
    const browserRect = window.windowUtils.getBoundsWithoutFlushing(
      gBrowser.tabbox
    );
    const clickPosition = gZenUIManager._lastClickPosition || {
      clientX: browserRect.width / 2,
      clientY: browserRect.height / 2,
    };

    this.openGlance(
      {
        url: undefined,
        ...clickPosition,
        width: 0,
        height: 0,
        triggeringPrincipal:
          Services.scriptSecurityManager.getSystemPrincipal(),
      },
      currentTab,
      parentTab
    );
  }
}

window.gZenGlanceManager = new nsZenGlanceManager();
