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

var { XPCOMUtils } = ChromeUtils.importESModule(
  "resource://gre/modules/XPCOMUtils.sys.mjs"
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
  PageWireframes: "resource:///modules/sessionstore/PageWireframes.sys.mjs",
});

const ZERO_DELAY_ACTIVATION_TIME = 300;

/**
 * Detailed preview card that displays when hovering a tab
 */
export default class TabHoverPreviewPanel {
  constructor(panel) {
    this._panel = panel;
    this._win = panel.ownerGlobal;
    this._tab = null;
    this._thumbnailElement = null;

    // Observe changes to this tab's DOM, and
    // update the preview if the tab title changes
    this._tabObserver = new this._win.MutationObserver(
      (mutationList, _observer) => {
        for (const mutation of mutationList) {
          if (mutation.attributeName === "label") {
            this._updatePreview();
          }
        }
      }
    );

    this._setExternalPopupListeners();

    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_prefDisableAutohide",
      "ui.popup.disable_autohide",
      false
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_prefPreviewDelay",
      "ui.tooltip.delay_ms"
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_prefDisplayThumbnail",
      "browser.tabs.hoverPreview.showThumbnails",
      false
    );
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "_prefCollectWireframes",
      "browser.history.collectWireframes"
    );

    this._panelOpener = new TabPreviewPanelTimedFunction(
      () => {
        this._panel.openPopup(this._tab, this.#popupOptions);
      },
      this._prefPreviewDelay,
      ZERO_DELAY_ACTIVATION_TIME,
      this._win
    );
  }

  get #verticalMode() {
    return this._win.gBrowser.tabContainer.verticalMode;
  }

  get #popupOptions() {
    if (!this.#verticalMode) {
      return {
        position: "bottomleft topleft",
        x: 0,
        y: -2,
      };
    }
    if (!this._win.SidebarController._positionStart) {
      return {
        position: "topleft topright",
        x: 0,
        y: 4,
      };
    }
    return {
      position: "topright topleft",
      x: 0,
      y: 4,
    };
  }

  getPrettyURI(uri) {
    try {
      let url = new URL(uri);
      if (url.protocol == "about:" && url.pathname == "reader") {
        url = new URL(url.searchParams.get("url"));
      }

      if (url.protocol === "about:") {
        return url.href;
      }
      return `${url.hostname}`.replace(/^w{3}\./, "");
    } catch {
      return uri;
    }
  }

  _hasValidWireframeState(tab) {
    return (
      this._prefCollectWireframes &&
      this._prefDisplayThumbnail &&
      tab &&
      !tab.selected &&
      !!lazy.PageWireframes.getWireframeState(tab)
    );
  }

  _hasValidThumbnailState(tab) {
    return (
      this._prefDisplayThumbnail &&
      tab &&
      tab.linkedBrowser &&
      !tab.getAttribute("pending") &&
      !tab.selected
    );
  }

  _maybeRequestThumbnail() {
    let tab = this._tab;

    if (!this._hasValidThumbnailState(tab)) {
      let wireframeElement = lazy.PageWireframes.getWireframeElementForTab(tab);
      if (wireframeElement) {
        this._thumbnailElement = wireframeElement;
        this._updatePreview();
      }
      return;
    }
    let thumbnailCanvas = this._win.document.createElement("canvas");
    thumbnailCanvas.width = 280 * this._win.devicePixelRatio;
    thumbnailCanvas.height = 140 * this._win.devicePixelRatio;

    this._win.PageThumbs.captureTabPreviewThumbnail(
      tab.linkedBrowser,
      thumbnailCanvas
    )
      .then(() => {
        // in case we've changed tabs after capture started, ensure we still want to show the thumbnail
        if (this._tab == tab && this._hasValidThumbnailState(tab)) {
          this._thumbnailElement = thumbnailCanvas;
          this._updatePreview();
        }
      })
      .catch(e => {
        // Most likely the window was killed before capture completed, so just log the error
        console.error(e);
      });
  }

  activate(tab) {
    if (this._isDisabled()) {
      return;
    }

    this._tab = tab;
    this._tabObserver.observe(this._tab, {
      attributes: true,
    });

    // Calling `moveToAnchor` in advance of the call to `openPopup` ensures
    // that race conditions can be avoided in cases where the user hovers
    // over a different tab while the preview panel is still opening.
    // This will ensure the move operation is carried out even if the popup is
    // in an intermediary state (opening but not fully open).
    //
    // If the popup is closed this call will be ignored.
    this._movePanel();

    this._thumbnailElement = null;
    this._maybeRequestThumbnail();
    if (this._panel.state == "open" || this._panel.state == "showing") {
      this._updatePreview();
    }
    this._panelOpener.execute();
    this._win.addEventListener("TabSelect", this);
    this._panel.addEventListener("popupshowing", this);
  }

  deactivate(leavingTab = null) {
    if (leavingTab) {
      if (this._tab != leavingTab) {
        return;
      }
      this._win.requestAnimationFrame(() => {
        if (this._tab == leavingTab) {
          this.deactivate();
        }
      });
      return;
    }
    this._tab = null;
    this._tabObserver.disconnect();
    this._thumbnailElement = null;
    this._panel.removeEventListener("popupshowing", this);
    this._win.removeEventListener("TabSelect", this);
    if (!this._prefDisableAutohide) {
      this._panel.hidePopup();
    }
    this._panelOpener.setZeroDelay();
  }

  handleEvent(e) {
    switch (e.type) {
      case "popupshowing":
        this._updatePreview();
        break;
      case "TabSelect":
        this.deactivate();
        break;
    }
  }

  _updatePreview() {
    this._panel.querySelector(".tab-preview-title").textContent =
      this._displayTitle;
    this._panel.querySelector(".tab-preview-uri").textContent =
      this._displayURI;

    if (this._win.gBrowser.showPidAndActiveness) {
      this._panel.querySelector(".tab-preview-pid").textContent =
        this._displayPids;
      this._panel.querySelector(".tab-preview-activeness").textContent =
        this._displayActiveness;
    } else {
      this._panel.querySelector(".tab-preview-pid").textContent = "";
      this._panel.querySelector(".tab-preview-activeness").textContent = "";
    }

    let thumbnailContainer = this._panel.querySelector(
      ".tab-preview-thumbnail-container"
    );
    thumbnailContainer.classList.toggle(
      "hide-thumbnail",
      !this._hasValidThumbnailState(this._tab) &&
        !this._hasValidWireframeState(this._tab)
    );
    if (thumbnailContainer.firstChild != this._thumbnailElement) {
      thumbnailContainer.replaceChildren();
      if (this._thumbnailElement) {
        thumbnailContainer.appendChild(this._thumbnailElement);
      }
      this._panel.dispatchEvent(
        new CustomEvent("previewThumbnailUpdated", {
          detail: {
            thumbnail: this._thumbnailElement,
          },
        })
      );
    }
    this._movePanel();
  }

  _movePanel() {
    if (this._tab) {
      this._panel.moveToAnchor(
        this._tab,
        this.#popupOptions.position,
        this.#popupOptions.x,
        this.#popupOptions.y
      );
    }
  }

  /**
   * Listen for any panels or menupopups that open or close anywhere else in the DOM tree
   * and maintain a list of the ones that are currently open.
   * This is used to disable tab previews until such time as the other panels are closed.
   */
  _setExternalPopupListeners() {
    // Since the tab preview panel is lazy loaded, there is a possibility that panels could
    // already be open on init. Therefore we need to initialize _openPopups with existing panels
    // the first time.
    const initialPopups = this._win.document.querySelectorAll(
      "panel[panelopen=true]:not(#tab-preview-panel), panel[animating=true]:not(#tab-preview-panel), menupopup[open=true]"
    );
    this._openPopups = new Set(initialPopups);

    const handleExternalPopupEvent = (eventName, setMethod) => {
      this._win.addEventListener(eventName, ev => {
        if (
          ev.target !== this._panel &&
          (ev.target.nodeName == "panel" || ev.target.nodeName == "menupopup")
        ) {
          this._openPopups[setMethod](ev.target);
        }
      });
    };
    handleExternalPopupEvent("popupshowing", "add");
    handleExternalPopupEvent("popuphiding", "delete");
  }

  _isDisabled() {
    return (
      // Other popups are open.
      this._openPopups.size ||
      // TODO (bug 1899556): for now disable in background windows, as there are
      // issues with windows ordering on Linux (bug 1897475), plus intermittent
      // persistence of previews after session restore (bug 1888148).
      this._win != Services.focus.activeWindow
    );
  }

  get _displayTitle() {
    if (!this._tab) {
      return "";
    }
    return this._tab.textLabel.textContent;
  }

  get _displayURI() {
    if (!this._tab || !this._tab.linkedBrowser) {
      return "";
    }
    return this.getPrettyURI(this._tab.linkedBrowser.currentURI.spec);
  }

  get _displayPids() {
    const pids = this._win.gBrowser.getTabPids(this._tab);
    if (!pids.length) {
      return "";
    }

    let pidLabel = pids.length > 1 ? "pids" : "pid";
    return `${pidLabel}: ${pids.join(", ")}`;
  }

  get _displayActiveness() {
    return this._tab?.linkedBrowser?.docShellIsActive ? "[A]" : "";
  }
}

/**
 * A wrapper that allows for delayed function execution, but with the
 * ability to "zero" (i.e. cancel) the delay for a predetermined period
 */
class TabPreviewPanelTimedFunction {
  constructor(target, delay, zeroDelayTime, win) {
    this._target = target;
    this._delay = delay;
    this._zeroDelayTime = zeroDelayTime;
    this._win = win;

    this._timer = null;
    this._useZeroDelay = false;
  }

  execute() {
    if (this.delayActive) {
      return;
    }

    // Always setting a timer, even in the situation where the
    // delay is zero, seems to prevent a class of race conditions
    // where multiple tabs are hovered in quick succession
    this._timer = this._win.setTimeout(
      () => {
        this._timer = null;
        this._target();
      },
      this._useZeroDelay ? 0 : this._delay
    );
  }

  clear() {
    if (this._timer) {
      this._win.clearTimeout(this._timer);
      this._timer = null;
    }
  }

  setZeroDelay() {
    this.clear();

    if (this._useZeroDelay) {
      return;
    }

    this._win.setTimeout(() => {
      this._useZeroDelay = false;
    }, this._zeroDelayTime);
    this._useZeroDelay = true;
  }

  get delayActive() {
    return this._timer !== null;
  }
}
