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

/**
 * Tab previews utility, produces thumbnails
 */
var tabPreviews = {
  get aspectRatio() {
    let { PageThumbUtils } = ChromeUtils.importESModule(
      "resource://gre/modules/PageThumbUtils.sys.mjs"
    );
    let [width, height] = PageThumbUtils.getThumbnailSize(window);
    delete this.aspectRatio;
    return (this.aspectRatio = height / width);
  },

  /**
   * Get the stored thumbnail URL for a given page URL and wait up to 1s for it
   * to load. If the browser is discarded and there is no stored thumbnail, the
   * image URL will fail to load and this method will return null after 1s.
   * Callers should handle this case by doing nothing or using a fallback image.
   *
   * @param {string} uri The page URL.
   * @returns {Promise<Image|null>}
   */
  loadImage: async function tabPreviews_loadImage(uri) {
    let img = new Image();
    img.src = PageThumbs.getThumbnailURL(uri);
    if (img.complete && img.naturalWidth) {
      return img;
    }
    return new Promise(resolve => {
      const controller = new AbortController();
      img.addEventListener(
        "load",
        () => {
          clearTimeout(timeout);
          controller.abort();
          resolve(img);
        },
        { signal: controller.signal }
      );
      const timeout = setTimeout(() => {
        controller.abort();
        resolve(null);
      }, 1000);
    });
  },

  /**
   * For a given tab, retrieve a preview thumbnail (a canvas or an image) from
   * storage or capture a new one. If the tab's URL has changed since the
   * previous call, the thumbnail will be regenerated.
   *
   * @param {MozTabbrowserTab} aTab The tab to get a preview for.
   * @returns {Promise<HTMLCanvasElement|Image|null>} Resolves to...
   * @resolves {HTMLCanvasElement} If a thumbnail can NOT be captured and stored
   *   for the tab, or if the tab is still loading, a snapshot is taken and
   *   returned as a canvas. It may be cached as a canvas (separately from
   *   thumbnail storage) in aTab.__thumbnail if the tab is finished loading. If
   *   the snapshot CAN be stored as a thumbnail, the snapshot is converted to a
   *   blob image and drawn in the returned canvas, but the image is added to
   *   thumbnail storage and cached in aTab.__thumbnail.
   * @resolves {Image} A cached blob image from a previous thumbnail capture.
   *   e.g. <img src="moz-page-thumb://thumbnails/?url=foo.com&revision=bar">
   * @resolves {null} If a thumbnail cannot be captured for any reason (e.g.
   *   because the tab is discarded) and there is no cached/stored thumbnail.
   */
  get: async function tabPreviews_get(aTab) {
    let browser = aTab.linkedBrowser;
    let uri = browser.currentURI.spec;

    // Invalidate the cached thumbnail since the tab has changed.
    if (aTab.__thumbnail_lastURI && aTab.__thumbnail_lastURI != uri) {
      aTab.__thumbnail = null;
      aTab.__thumbnail_lastURI = null;
    }

    // A cached thumbnail (not from thumbnail storage) is available.
    if (aTab.__thumbnail) {
      return aTab.__thumbnail;
    }

    // This means the browser is discarded. Try to load a stored thumbnail, and
    // use a fallback style otherwise.
    if (!browser.browsingContext) {
      return this.loadImage(uri);
    }

    // Don't cache or store the thumbnail if the tab is still loading.
    return this.capture(aTab, !aTab.hasAttribute("busy"));
  },

  /**
   * For a given tab, capture a preview thumbnail (a canvas), optionally cache
   * it in aTab.__thumbnail, and possibly store it in thumbnail storage.
   *
   * @param {MozTabbrowserTab} aTab The tab to capture a preview for.
   * @param {boolean} aShouldCache Cache/store the captured thumbnail?
   * @returns {Promise<HTMLCanvasElement|null>} Resolves to...
   * @resolves {HTMLCanvasElement} A snapshot of the tab's content. If the
   *   snapshot is safe for storage and aShouldCache is true, the snapshot is
   *   converted to a blob image, stored and cached, and drawn in the returned
   *   canvas. The thumbnail can then be recovered even if the browser is
   *   discarded. Otherwise, the canvas itself is cached in aTab.__thumbnail.
   * @resolves {null} If a fatal exception occurred during thumbnail capture.
   */
  capture: async function tabPreviews_capture(aTab, aShouldCache) {
    let browser = aTab.linkedBrowser;
    let uri = browser.currentURI.spec;
    let canvas = PageThumbs.createCanvas(window);
    const doStore = await PageThumbs.shouldStoreThumbnail(browser);

    if (doStore && aShouldCache) {
      await PageThumbs.captureAndStore(browser);
      let img = await this.loadImage(uri);
      if (img) {
        // Cache the stored blob image for future use.
        aTab.__thumbnail = img;
        aTab.__thumbnail_lastURI = uri;
        // Draw the stored blob image in the canvas.
        canvas.getContext("2d").drawImage(img, 0, 0);
      } else {
        canvas = null;
      }
    } else {
      try {
        await PageThumbs.captureToCanvas(browser, canvas);
        if (aShouldCache) {
          // Cache the canvas itself for future use.
          aTab.__thumbnail = canvas;
          aTab.__thumbnail_lastURI = uri;
        }
      } catch (error) {
        console.error(error);
        canvas = null;
      }
    }

    return canvas;
  },
};

var tabPreviewPanelHelper = {
  opening(host) {
    host.panel.hidden = false;

    var handler = this._generateHandler(host);
    host.panel.addEventListener("popupshown", handler);
    host.panel.addEventListener("popuphiding", handler);

    host._prevFocus = document.commandDispatcher.focusedElement;
  },
  _generateHandler(host) {
    var self = this;
    return function listener(event) {
      if (event.target == host.panel) {
        host.panel.removeEventListener(event.type, listener);
        self["_" + event.type](host);
      }
    };
  },
  _popupshown(host) {
    if ("setupGUI" in host) {
      host.setupGUI();
    }
  },
  _popuphiding(host) {
    if ("suspendGUI" in host) {
      host.suspendGUI();
    }

    if (host._prevFocus) {
      Services.focus.setFocus(
        host._prevFocus,
        Ci.nsIFocusManager.FLAG_NOSCROLL
      );
      host._prevFocus = null;
    } else {
      gBrowser.selectedBrowser.focus();
    }

    if (host.tabToSelect) {
      gBrowser.selectedTab = host.tabToSelect;
      host.tabToSelect = null;
    }
  },
};

/**
 * Ctrl-Tab panel
 */
var ctrlTab = {
  maxTabPreviews: 7,
  get panel() {
    delete this.panel;
    return (this.panel = document.getElementById("ctrlTab-panel"));
  },
  get showAllButton() {
    delete this.showAllButton;
    this.showAllButton = document.createXULElement("button");
    this.showAllButton.id = "ctrlTab-showAll";
    this.showAllButton.addEventListener("mouseover", this);
    this.showAllButton.addEventListener("command", this);
    this.showAllButton.addEventListener("click", this);
    document
      .getElementById("ctrlTab-showAll-container")
      .appendChild(this.showAllButton);
    return this.showAllButton;
  },
  get previews() {
    delete this.previews;
    this.previews = [];
    let previewsContainer = document.getElementById("ctrlTab-previews");
    for (let i = 0; i < this.maxTabPreviews; i++) {
      let preview = this._makePreview();
      previewsContainer.appendChild(preview);
      this.previews.push(preview);
    }
    this.previews.push(this.showAllButton);
    return this.previews;
  },
  get keys() {
    var keys = {};
    ["close", "find", "selectAll"].forEach(function (key) {
      keys[key] = document
        .getElementById("key_" + key)
        .getAttribute("key")
        .toLocaleLowerCase()
        .charCodeAt(0);
    });
    delete this.keys;
    return (this.keys = keys);
  },
  _selectedIndex: 0,
  get selected() {
    return this._selectedIndex < 0
      ? document.activeElement
      : this.previews[this._selectedIndex];
  },
  get isOpen() {
    return (
      this.panel.state == "open" || this.panel.state == "showing" || this._timer
    );
  },
  get tabCount() {
    return this.tabList.length;
  },
  get tabPreviewCount() {
    return Math.min(this.maxTabPreviews, this.tabCount);
  },

  get tabList() {
    return this._recentlyUsedTabs;
  },

  init: function ctrlTab_init() {
    if (!this._recentlyUsedTabs) {
      this._initRecentlyUsedTabs();
      this._init(true);
    }
  },

  uninit: function ctrlTab_uninit() {
    if (this._recentlyUsedTabs) {
      this._recentlyUsedTabs = null;
      this._init(false);
    }
  },

  prefName: "browser.ctrlTab.sortByRecentlyUsed",
  readPref: function ctrlTab_readPref() {
    var enable =
      Services.prefs.getBoolPref(this.prefName) &&
      !Services.prefs.getBoolPref(
        "browser.ctrlTab.disallowForScreenReaders",
        false
      );

    if (enable) {
      this.init();
    } else {
      this.uninit();
    }
  },
  observe() {
    this.readPref();
  },

  _makePreview() {
    let preview = document.createXULElement("button");
    preview.className = "ctrlTab-preview";
    preview.setAttribute("pack", "center");
    preview.setAttribute("flex", "1");
    preview.addEventListener("mouseover", this);
    preview.addEventListener("command", this);
    preview.addEventListener("click", this);

    let previewInner = document.createXULElement("vbox");
    previewInner.className = "ctrlTab-preview-inner";
    preview.appendChild(previewInner);

    let canvas = (preview._canvas = document.createXULElement("hbox"));
    canvas.className = "ctrlTab-canvas";
    previewInner.appendChild(canvas);

    let faviconContainer = document.createXULElement("hbox");
    faviconContainer.className = "ctrlTab-favicon-container";
    previewInner.appendChild(faviconContainer);

    let favicon = (preview._favicon = document.createXULElement("image"));
    favicon.className = "ctrlTab-favicon";
    faviconContainer.appendChild(favicon);

    let label = (preview._label = document.createXULElement("label"));
    label.className = "ctrlTab-label plain";
    label.setAttribute("crop", "end");
    previewInner.appendChild(label);

    return preview;
  },

  updatePreviews: function ctrlTab_updatePreviews() {
    for (let i = 0; i < this.previews.length; i++) {
      this.updatePreview(this.previews[i], this.tabList[i]);
    }

    document.l10n.setAttributes(
      this.showAllButton,
      "tabbrowser-ctrl-tab-list-all-tabs",
      { tabCount: this.tabCount }
    );
    this.showAllButton.hidden = !gTabsPanel.canOpen;
  },

  updatePreview: function ctrlTab_updatePreview(aPreview, aTab) {
    if (aPreview == this.showAllButton) {
      return;
    }

    aPreview._tab = aTab;

    if (aTab) {
      let canvas = aPreview._canvas;
      let canvasWidth = this.canvasWidth;
      let canvasHeight = this.canvasHeight;
      let existingPreview = canvas.firstChild;
      if (!existingPreview) {
        let placeholder = document.createElement("img");
        placeholder.className = "ctrlTab-placeholder";
        placeholder.setAttribute("width", canvasWidth);
        placeholder.setAttribute("height", canvasHeight);
        placeholder.setAttribute("alt", "");
        canvas.appendChild(placeholder);
        existingPreview = placeholder;
      }
      tabPreviews
        .get(aTab)
        .then(img => {
          switch (aPreview._tab) {
            case aTab:
              if (img) {
                img.style.width = canvasWidth + "px";
                img.style.height = canvasHeight + "px";
                canvas.replaceChild(img, existingPreview);
              }
              break;
            case null:
              // The preview panel is not open, so don't render anything.
              this._clearCanvas(canvas);
              break;
            // If the tab exists but it has changed since updatePreview was
            // called, the preview will likely be handled by a later
            // updatePreview call, e.g. on TabAttrModified.
          }
        })
        .catch(error => console.error(error));

      aPreview._label.setAttribute("value", aTab.label);
      aPreview.setAttribute("tooltiptext", aTab.label);
      if (aTab.image) {
        aPreview._favicon.setAttribute("src", aTab.image);
      } else {
        aPreview._favicon.removeAttribute("src");
      }
      aPreview.hidden = false;
    } else {
      this._clearCanvas(aPreview._canvas);
      aPreview.hidden = true;
      aPreview._label.removeAttribute("value");
      aPreview.removeAttribute("tooltiptext");
      aPreview._favicon.removeAttribute("src");
    }
  },

  // Remove previous preview images from the canvas box.
  _clearCanvas(canvas) {
    canvas.replaceChildren();
  },

  advanceFocus: function ctrlTab_advanceFocus(aForward) {
    let selectedIndex = this.previews.indexOf(this.selected);
    do {
      selectedIndex += aForward ? 1 : -1;
      if (selectedIndex < 0) {
        selectedIndex = this.previews.length - 1;
      } else if (selectedIndex >= this.previews.length) {
        selectedIndex = 0;
      }
    } while (this.previews[selectedIndex].hidden);

    if (this._selectedIndex == -1) {
      // Focus is already in the panel.
      this.previews[selectedIndex].focus();
    } else {
      this._selectedIndex = selectedIndex;
    }

    if (this.previews[selectedIndex]._tab) {
      gBrowser.warmupTab(this.previews[selectedIndex]._tab);
    }

    if (this._timer) {
      clearTimeout(this._timer);
      this._timer = null;
      this._openPanel();
    }
  },

  pick: function ctrlTab_pick(aPreview) {
    if (!this.tabCount) {
      return;
    }

    var select = aPreview || this.selected;

    if (select == this.showAllButton) {
      this.showAllTabs("ctrltab-all-tabs-button");
    } else {
      this.close(select._tab);
    }
  },

  showAllTabs: function ctrlTab_showAllTabs(aEntrypoint = "unknown") {
    this.close();
    gTabsPanel.showAllTabsPanel(null, aEntrypoint);
  },

  remove: function ctrlTab_remove(aPreview) {
    if (aPreview._tab) {
      gBrowser.removeTab(aPreview._tab);
    }
  },

  attachTab: function ctrlTab_attachTab(aTab, aPos) {
    // If the tab is hidden, don't add it to the list unless it's selected
    // (Normally hidden tabs would be unhidden when selected, but that doesn't
    // happen for Firefox View).
    if (aTab.closing || (aTab.hidden && !aTab.selected)) {
      return;
    }

    // If the tab is already in the list, remove it before re-inserting it.
    this.detachTab(aTab);

    if (aPos == 0) {
      this._recentlyUsedTabs.unshift(aTab);
    } else if (aPos) {
      this._recentlyUsedTabs.splice(aPos, 0, aTab);
    } else {
      this._recentlyUsedTabs.push(aTab);
    }
  },

  detachTab: function ctrlTab_detachTab(aTab) {
    var i = this._recentlyUsedTabs.indexOf(aTab);
    if (i >= 0) {
      this._recentlyUsedTabs.splice(i, 1);
    }
  },

  open: function ctrlTab_open() {
    if (this.isOpen) {
      return;
    }

    this.canvasWidth = Math.ceil(
      (screen.availWidth * 0.85) / this.maxTabPreviews
    );
    this.canvasHeight = Math.round(this.canvasWidth * tabPreviews.aspectRatio);
    this.updatePreviews();
    this._selectedIndex = 1;
    gBrowser.warmupTab(this.selected._tab);

    // Add a slight delay before showing the UI, so that a quick
    // "ctrl-tab" keypress just flips back to the MRU tab.
    this._timer = setTimeout(() => {
      this._timer = null;
      this._openPanel();
    }, 200);
  },

  _openPanel: function ctrlTab_openPanel() {
    tabPreviewPanelHelper.opening(this);

    let width = Math.min(
      screen.availWidth * 0.99,
      this.canvasWidth * 1.25 * this.tabPreviewCount
    );
    this.panel.style.width = width + "px";
    var estimateHeight = this.canvasHeight * 1.25 + 75;
    this.panel.openPopupAtScreen(
      screen.availLeft + (screen.availWidth - width) / 2,
      screen.availTop + (screen.availHeight - estimateHeight) / 2,
      false
    );
  },

  close: function ctrlTab_close(aTabToSelect) {
    if (!this.isOpen) {
      return;
    }

    if (this._timer) {
      clearTimeout(this._timer);
      this._timer = null;
      this.suspendGUI();
      if (aTabToSelect) {
        gBrowser.selectedTab = aTabToSelect;
      }
      return;
    }

    this.tabToSelect = aTabToSelect;
    this.panel.hidePopup();
  },

  setupGUI: function ctrlTab_setupGUI() {
    this.selected.focus();
    this._selectedIndex = -1;
  },

  suspendGUI: function ctrlTab_suspendGUI() {
    for (let preview of this.previews) {
      this.updatePreview(preview, null);
    }
  },

  onKeyDown(event) {
    let action = ShortcutUtils.getSystemActionForEvent(event);
    if (action != ShortcutUtils.CYCLE_TABS) {
      return;
    }

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

    if (this.isOpen) {
      this.advanceFocus(!event.shiftKey);
      return;
    }

    if (event.shiftKey) {
      this.showAllTabs("shift-tab");
      return;
    }

    document.addEventListener("keyup", this, { mozSystemGroup: true });

    let tabs = gBrowser.visibleTabs;
    if (tabs.length > 2) {
      this.open();
    } else if (tabs.length == 2) {
      let index = tabs[0].selected ? 1 : 0;
      gBrowser.selectedTab = tabs[index];
    }
  },

  onKeyPress(event) {
    if (!this.isOpen || !event.ctrlKey) {
      return;
    }

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

    if (event.keyCode == event.DOM_VK_DELETE) {
      this.remove(this.selected);
      return;
    }

    switch (event.charCode) {
      case this.keys.close:
        this.remove(this.selected);
        break;
      case this.keys.find:
        this.showAllTabs("ctrltab-key-find");
        break;
      case this.keys.selectAll:
        this.showAllTabs("ctrltab-key-selectAll");
        break;
    }
  },

  removeClosingTabFromUI: function ctrlTab_removeClosingTabFromUI(aTab) {
    if (this.tabCount == 2) {
      this.close();
      return;
    }

    this.updatePreviews();

    if (this.selected.hidden) {
      this.advanceFocus(false);
    }
    if (this.selected == this.showAllButton) {
      this.advanceFocus(false);
    }

    // If the current tab is removed, another tab can steal our focus.
    if (aTab.selected && this.panel.state == "open") {
      setTimeout(
        function (selected) {
          selected.focus();
        },
        0,
        this.selected
      );
    }
  },

  handleEvent: function ctrlTab_handleEvent(event) {
    switch (event.type) {
      case "SSWindowRestored":
        this._initRecentlyUsedTabs();
        break;
      case "TabAttrModified":
        // tab attribute modified (i.e. label, busy, image)
        // update preview only if tab attribute modified in the list
        if (
          event.detail.changed.some(elem =>
            ["label", "busy", "image"].includes(elem)
          )
        ) {
          for (let i = this.previews.length - 1; i >= 0; i--) {
            if (
              this.previews[i]._tab &&
              this.previews[i]._tab == event.target
            ) {
              this.updatePreview(this.previews[i], event.target);
              break;
            }
          }
        }
        break;
      case "TabSelect": {
        this.attachTab(event.target, 0);
        // If the previous tab was hidden (e.g. Firefox View), remove it from
        // the list when it's deselected.
        let previousTab = event.detail.previousTab;
        if (previousTab.hidden) {
          this.detachTab(previousTab);
        }
        break;
      }
      case "TabOpen":
        this.attachTab(event.target, 1);
        break;
      case "TabClose":
        this.detachTab(event.target);
        if (this.isOpen) {
          this.removeClosingTabFromUI(event.target);
        }
        break;
      case "TabHide":
        this.detachTab(event.target);
        break;
      case "TabShow":
        this.attachTab(event.target);
        this._sortRecentlyUsedTabs();
        break;
      case "keydown":
        this.onKeyDown(event);
        break;
      case "keypress":
        this.onKeyPress(event);
        break;
      case "keyup":
        // During cycling tabs, we avoid sending keyup event to content document.
        event.preventDefault();
        event.stopPropagation();

        if (event.keyCode === event.DOM_VK_CONTROL) {
          document.removeEventListener("keyup", this, { mozSystemGroup: true });

          if (this.isOpen) {
            this.pick();
          }
        }
        break;
      case "popupshowing":
        if (event.target.id == "menu_viewPopup") {
          document.getElementById("menu_showAllTabs").hidden =
            !gTabsPanel.canOpen;
        }
        break;
      case "mouseover":
        // relatedTarget is the element the mouse came from. It is null when we
        // get a synthetic mouse event.
        if (event.relatedTarget) {
          event.currentTarget.focus();
        }
        break;
      case "command":
        this.pick(event.currentTarget);
        break;
      case "click":
        if (event.button == 1) {
          this.remove(event.currentTarget);
        } else if (AppConstants.platform == "macosx" && event.button == 2) {
          // Control+click is a right click on macOS, but in this case we want
          // to handle it like a left click.
          this.pick(event.currentTarget);
        }
        break;
    }
  },

  filterForThumbnailExpiration(aCallback) {
    // Save a few more thumbnails than we actually display, so that when tabs
    // are closed, the previews we add instead still get thumbnails.
    const extraThumbnails = 3;
    const thumbnailCount = Math.min(
      this.tabPreviewCount + extraThumbnails,
      this.tabCount
    );

    let urls = [];
    for (let i = 0; i < thumbnailCount; i++) {
      urls.push(this.tabList[i].linkedBrowser.currentURI.spec);
    }

    aCallback(urls);
  },
  _sortRecentlyUsedTabs() {
    this._recentlyUsedTabs.sort(
      (tab1, tab2) => tab2.lastAccessed - tab1.lastAccessed
    );
  },
  _initRecentlyUsedTabs() {
    this._recentlyUsedTabs = Array.prototype.filter.call(
      gBrowser.tabs,
      tab => !tab.closing && !tab.hidden
    );
    this._sortRecentlyUsedTabs();
  },

  _init: function ctrlTab__init(enable) {
    var toggleEventListener = enable
      ? "addEventListener"
      : "removeEventListener";

    window[toggleEventListener]("SSWindowRestored", this);

    var tabContainer = gBrowser.tabContainer;
    tabContainer[toggleEventListener]("TabOpen", this);
    tabContainer[toggleEventListener]("TabAttrModified", this);
    tabContainer[toggleEventListener]("TabSelect", this);
    tabContainer[toggleEventListener]("TabClose", this);
    tabContainer[toggleEventListener]("TabHide", this);
    tabContainer[toggleEventListener]("TabShow", this);

    if (enable) {
      document.addEventListener("keydown", this, { mozSystemGroup: true });
    } else {
      document.removeEventListener("keydown", this, { mozSystemGroup: true });
    }
    document[toggleEventListener]("keypress", this);
    gBrowser.tabbox.handleCtrlTab = !enable;

    if (enable) {
      PageThumbs.addExpirationFilter(this);
    } else {
      PageThumbs.removeExpirationFilter(this);
    }

    // If we're not running, hide the "Show All Tabs" menu item,
    // as Shift+Ctrl+Tab will be handled by the tab bar.
    document.getElementById("menu_showAllTabs").hidden = !enable;
    document
      .getElementById("menu_viewPopup")
      [toggleEventListener]("popupshowing", this);
  },
};
