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

"use strict";

// This is loaded into chrome windows with the subscript loader. Wrap in
// a block to prevent accidentally leaking globals onto `window`.
{
  ChromeUtils.defineESModuleGetters(this, {
    DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
  });

  /**
   * A shared task which updates the urlbar indicator whenever:
   * - A split view is activated or deactivated.
   * - The active tab of a split view changes.
   * - The order of tabs in a split view changes.
   *
   * @type {DeferredTask}
   */
  const updateUrlbarButton = new DeferredTask(() => {
    const { activeSplitView, selectedTab } = gBrowser;
    const button = document.getElementById("split-view-button");
    if (activeSplitView) {
      const activeIndex = activeSplitView.tabs.indexOf(selectedTab);
      button.hidden = false;
      button.setAttribute("data-active-index", activeIndex);
    } else {
      button.hidden = true;
      button.removeAttribute("data-active-index");
    }
  }, 0);

  class MozTabSplitViewWrapper extends MozXULElement {
    /** @type {MutationObserver} */
    #tabChangeObserver;

    /** @type {MozTabbrowserTab[]} */
    #tabs = [];

    #storedPanelWidths = new WeakMap();

    /**
     * @returns {boolean}
     */
    get hasActiveTab() {
      return this.hasAttribute("hasactivetab");
    }

    /**
     * @returns {MozTabbrowserGroup}
     */
    get group() {
      return gBrowser.isTabGroup(this.parentElement)
        ? this.parentElement
        : null;
    }

    /**
     * @typedef {object} TabSplitViewStateData
     *   State of a tab group inside of an open window.
     * @property {number} id
     *   Unique ID of the tab splitview.
     * @property {number} numberOfTabs
     *   Number of expected tabs in the splitview.
     *
     * Collect data related to a single tab splitview, synchronously.
     *
     * @returns {TabSplitViewStateData}
     *   Serialized splitview data
     */
    get state() {
      return {
        id: this.splitViewId,
        numberOfTabs: this.tabs.length,
      };
    }

    /**
     * @param {boolean} val
     */
    set hasActiveTab(val) {
      this.toggleAttribute("hasactivetab", val);
    }

    get multiselected() {
      return this.hasAttribute("multiselected");
    }

    constructor() {
      super();
      XPCOMUtils.defineLazyPreferenceGetter(
        this,
        "_hasUsedSplitView",
        "browser.tabs.splitview.hasUsed",
        false
      );
    }

    connectedCallback() {
      // Set up TabSelect listener, as this gets
      // removed in disconnectedCallback
      this.ownerGlobal.addEventListener("TabSelect", this);

      this.#observeTabChanges();
      this.#restorePanelWidths();

      if (this.hasActiveTab) {
        this.#activate();
      }

      if (this._initialized) {
        return;
      }

      if (!this._hasUsedSplitView) {
        Services.prefs.setBoolPref("browser.tabs.splitview.hasUsed", true);
      }

      this._initialized = true;

      this.textContent = "";

      // Mirroring MozTabbrowserTab
      this.container = gBrowser.tabContainer;
    }

    disconnectedCallback() {
      this.#tabChangeObserver?.disconnect();
      this.ownerGlobal.removeEventListener("TabSelect", this);
      this.#deactivate();
      this.#resetPanelWidths();
      this.container.dispatchEvent(
        new CustomEvent("SplitViewRemoved", {
          bubbles: true,
          composed: true,
        })
      );
    }

    #observeTabChanges() {
      if (!this.#tabChangeObserver) {
        this.#tabChangeObserver = new window.MutationObserver(() => {
          if (this.tabs.length) {
            this.hasActiveTab = this.tabs.some(tab => tab.selected);
            this.tabs.forEach((tab, index) => {
              // Renumber tabs so that a11y tools can tell users that a given
              // tab is "1 of 2" in the split view, for example.
              tab.setAttribute("aria-posinset", index + 1);
              tab.setAttribute("aria-setsize", this.tabs.length);
              tab.updateSplitViewAriaLabel(index);
            });
            this.dispatchEvent(
              new CustomEvent("SplitViewTabChange", {
                bubbles: true,
              })
            );
          } else {
            this.remove();
          }

          if (this.tabs.length < 2) {
            this.unsplitTabs();
          }
        });
      }
      this.#tabChangeObserver.observe(this, {
        childList: true,
      });
    }

    get splitViewId() {
      return parseInt(this.getAttribute("splitViewId"));
    }

    set splitViewId(val) {
      this.setAttribute("splitViewId", val);
    }

    /**
     * @returns {MozTabbrowserTab[]}
     */
    get tabs() {
      return Array.from(this.children).filter(node => node.matches("tab"));
    }

    get visible() {
      return this.tabs.every(tab => tab.visible);
    }

    get pinned() {
      return false;
    }

    /**
     * Get the list of tab panels from this split view.
     *
     * @returns {XULElement[]}
     */
    get panels() {
      const panels = [];
      for (const { linkedPanel } of this.#tabs) {
        const el = document.getElementById(linkedPanel);
        if (el) {
          panels.push(el);
        }
      }
      return panels;
    }

    /**
     * Show all Split View tabs in the content area.
     */
    #activate(skipShowPanels = false) {
      updateUrlbarButton.arm();
      if (!skipShowPanels) {
        gBrowser.showSplitViewPanels(this.#tabs);
      }
      this.container.dispatchEvent(
        new CustomEvent("TabSplitViewActivate", {
          detail: { tabs: this.#tabs, splitview: this },
          bubbles: true,
        })
      );
    }

    /**
     * Remove Split View tabs from the content area.
     */
    #deactivate(skipHidePanels = false) {
      if (!skipHidePanels) {
        gBrowser.hideSplitViewPanels(this.#tabs);
      }
      updateUrlbarButton.arm();
      this.container.dispatchEvent(
        new CustomEvent("TabSplitViewDeactivate", {
          detail: { tabs: this.#tabs, splitview: this },
          bubbles: true,
        })
      );
    }

    /**
     * Remove customized panel widths. Cache width values so that they can be
     * restored if this Split View is later reactivated.
     */
    #resetPanelWidths() {
      for (const panel of this.panels) {
        const width = panel.getAttribute("width");
        if (width) {
          this.#storedPanelWidths.set(panel, width);
          panel.removeAttribute("width");
          panel.style.removeProperty("width");
        }
      }
    }

    /**
     * Resize panel widths back to cached values.
     */
    #restorePanelWidths() {
      for (const panel of this.panels) {
        const width = this.#storedPanelWidths.get(panel);
        if (width) {
          panel.setAttribute("width", width);
          panel.style.setProperty("width", width + "px");
        }
      }
    }

    /**
     * Reset custom width on the right panel, allowing it to fill the rest of
     * the available space.
     */
    resetRightPanelWidth() {
      const panel = this.panels[1];
      this.#storedPanelWidths.delete(panel);
      panel.removeAttribute("width");
      panel.style.removeProperty("width");
    }

    /**
     * add tabs to the split view wrapper
     *
     * @param {MozTabbrowserTab[]} tabs
     * @param {object} [options]
     * @param {boolean} [options.isSessionRestore]
     * @param {int} [options.indexOfReplacedTab] [optional] Used if replacing a tab in the split view
     */
    addTabs(tabs, { isSessionRestore = false, indexOfReplacedTab = -1 } = {}) {
      for (let tab of tabs) {
        if (tab.pinned) {
          return;
        }
        let tabToMove =
          this.ownerGlobal === tab.ownerGlobal
            ? tab
            : gBrowser.adoptTab(tab, {
                tabIndex: gBrowser.tabs.at(-1)._tPos + 1,
                selectTab: tab.selected,
              });
        if (indexOfReplacedTab > -1 && indexOfReplacedTab < this.#tabs.length) {
          this.#tabs[indexOfReplacedTab] = tabToMove;
        } else {
          this.#tabs.push(tabToMove);
        }
        isSessionRestore
          ? this.appendChild(tab)
          : gBrowser.moveTabToSplitView(tabToMove, this, indexOfReplacedTab);
        if (tab === gBrowser.selectedTab) {
          this.hasActiveTab = true;
        }
      }

      if (this.hasActiveTab || isSessionRestore) {
        this.#activate();
        gBrowser.setIsSplitViewActive(this.hasActiveTab, this.#tabs);
      }
      // Attempt to update uriCount metric using the resulting tabs collection,
      // as tabs may not be added to the splitview if they are pinned etc.
      for (let tab of this.tabs) {
        let tabURI = tab.linkedBrowser.currentURI.spec;
        if (!isBlankPageURL(tabURI) && tabURI !== "about:opentabs") {
          // Add to the counter which tracks the number of URIs loaded into splitview tabs
          const index = tabs.indexOf(tab);
          const label = String(index + 1); // 0 -> "1" (LTR left), 1 -> "2" (LTR right)
          Glean.splitview.uriCount[label].add(1);
        }
      }
    }

    /**
     * Remove all tabs from the split view wrapper and delete the split view.
     */
    unsplitTabs() {
      gBrowser.unsplitTabs(this);
      gBrowser.setIsSplitViewActive(false, this.#tabs);
    }

    /**
     * Replace a tab in the split view with another tab
     */
    replaceTab(tabToReplace, newTab) {
      let indexOfReplacedTab = this.tabs.indexOf(tabToReplace);
      this.addTabs([newTab], { isSessionRestore: false, indexOfReplacedTab });

      // Get the adopted tab reference from the split view's internal tabs array.
      // If the tab was adopted from another window, the original newTab reference
      // is stale and points to the tab in the old window.
      let adoptedTab = this.#tabs[indexOfReplacedTab];

      // Select the adopted tab BEFORE removing the old one to prevent Firefox
      // from auto-selecting the wrong tab when the old selected tab is removed.
      if (tabToReplace.selected) {
        gBrowser.selectedTab = adoptedTab;
      }

      gBrowser.removeTab(tabToReplace);

      // We need to re-activate after removing one of the split view tabs
      this.#activate();
      gBrowser.setIsSplitViewActive(true, this.#tabs);
    }

    /**
     * Reverse order of the tabs in the split view wrapper.
     */
    reverseTabs() {
      const [firstTab, secondTab] = this.#tabs;
      gBrowser.moveTabBefore(secondTab, firstTab);
      this.#tabs = [secondTab, firstTab];
      gBrowser.showSplitViewPanels(this.#tabs);
      updateUrlbarButton.arm();
    }

    /**
     * Close all tabs in the split view wrapper and delete the split view.
     */
    close() {
      gBrowser.removeTabs(this.#tabs);
    }

    /**
     * @param {CustomEvent} event
     */
    on_TabSelect(event) {
      this.hasActiveTab = event.target.splitview === this;
      gBrowser.setIsSplitViewActive(this.hasActiveTab, this.#tabs);
      if (this.hasActiveTab) {
        this.#activate();
      } else {
        this.#deactivate(true);
      }
    }
  }

  customElements.define("tab-split-view-wrapper", MozTabSplitViewWrapper);
}
