/*
    ***** BEGIN LICENSE BLOCK *****
    
    Copyright © 2020 Corporation for Digital Scholarship
                     Vienna, Virginia, USA
                     https://www.zotero.org
    
    This file is part of Zotero.
    
    Zotero is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.
    
    Zotero is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.
    
    You should have received a copy of the GNU Affero General Public License
    along with Zotero.  If not, see <http://www.gnu.org/licenses/>.
    
    ***** END LICENSE BLOCK *****
*/

'use strict';

// Using 'import' breaks hooks
var React = require('react');
var ReactDOM = require('react-dom');
import TabBar from 'components/tabBar';
import { CSSIcon, CSSItemTypeIcon } from 'components/icons';

// Reduce loaded tabs limit if the system has 8 GB or less memory.
// TODO: Revise this after upgrading to Zotero 7
const MAX_LOADED_TABS = Services.sysinfo.getProperty("memsize") / 1024 / 1024 / 1024 <= 8 ? 3 : 5;
const UNLOAD_UNUSED_AFTER = 86400; // 24h

var Zotero_Tabs = new function () {
	Object.defineProperty(this, 'selectedID', {
		get: () => this._selectedID
	});

	Object.defineProperty(this, 'selectedType', {
		get: () => this._getTab(this._selectedID).tab.type
	});

	Object.defineProperty(this, 'selectedIndex', {
		get: () => this._getTab(this._selectedID).tabIndex
	});

	Object.defineProperty(this, 'deck', {
		get: () => document.getElementById('tabs-deck')
	});

	Object.defineProperty(this, 'numTabs', {
		get: () => this._tabs.length
	});

	Object.defineProperty(this, 'focusOptions', {
		get: () => this._focusOptions
	});

	Object.defineProperty(this, 'tabsMenuList', {
		get: () => document.getElementById('zotero-tabs-menu-list')
	});

	Object.defineProperty(this, 'tabsMenuPanel', {
		get: () => document.getElementById('zotero-tabs-menu-panel')
	});

	this._tabBarRef = React.createRef();
	this._tabs = [{
		id: 'zotero-pane',
		type: 'library',
		title: ''
	}];
	this._selectedID = 'zotero-pane';
	this._prevSelectedID = null;
	this._history = [];
	this._focusOptions = {};
	this._tabsMenuFilter = "";
	this._tabsMenuFocusedIndex = 0;
	this._tabsMenuIgnoreMouseover = false;

	this._unloadInterval = setInterval(() => {
		this.unloadUnusedTabs();
	}, 60000); // Trigger every minute

	this._getTab = function (id) {
		var tabIndex = this._tabs.findIndex(tab => tab.id == id);
		return { tab: this._tabs[tabIndex], tabIndex };
	};

	this._update = function () {
		this._tabBarRef.current.setTabs(this._tabs.map((tab) => {
			let icon = null;
			if (tab.id === 'zotero-pane') {
				let index = ZoteroPane.collectionsView?.selection?.focused;
				if (typeof index !== 'undefined' && ZoteroPane.collectionsView.getRow(index)) {
					let iconName = ZoteroPane.collectionsView.getIconName(index);
					icon = <CSSIcon name={iconName} className="tab-icon" />;
				}
			}
			else if (tab.data?.itemID) {
				try {
					let item = Zotero.Items.get(tab.data.itemID);
					icon = <CSSItemTypeIcon itemType={item.getItemTypeIconName(true)} className="tab-icon" />;
				}
				catch (e) {
					// item might not yet be loaded, we will get the icon on the next update
				}
			}

			return {
				id: tab.id,
				type: tab.type,
				title: tab.title,
				selected: tab.id == this._selectedID,
				icon,
			};
		}));
		// Disable File > Close menuitem if multiple tabs are open
		const multipleTabsOpen = this._tabs.length > 1;
		document.getElementById('cmd_close').setAttribute('disabled', multipleTabsOpen);
		var { tab } = this._getTab(this._selectedID);
		if (!tab) {
			return;
		}
		document.title = (tab.title.length ? tab.title + ' - ' : '') + Zotero.appName;
		if (this.isTabsMenuVisible()) {
			this.refreshTabsMenuList();
			if (document.activeElement.id !== "zotero-tabs-menu-filter") {
				focusTabsMenuEntry();
			}
		}
		// Disable tabs menu button when no reader tabs are present
		document.getElementById("zotero-tb-tabs-menu").disabled = this._tabs.length == 1;
		// Close tabs menu if all tabs are closed
		if (this._tabs.length == 1 && this.isTabsMenuVisible()) {
			this.tabsMenuPanel.hidePopup();
		}
	};

	this.getTabIDByItemID = function (itemID) {
		let tab = this._tabs.find(tab => tab.data && tab.data.itemID === itemID);
		return tab && tab.id;
	};

	this.setSecondViewState = function (tabID, state) {
		let { tab } = this._getTab(tabID);
		tab.data.secondViewState = state;
		Zotero.Session.debounceSave();
	};

	this.init = function () {
		ReactDOM.render(
			<TabBar
				ref={this._tabBarRef}
				onTabSelect={this.select.bind(this)}
				onTabMove={this.move.bind(this)}
				onTabClose={this.close.bind(this)}
				onContextMenu={this._openMenu.bind(this)}
				refocusReader={this.refocusReader.bind(this)}
			/>,
			document.getElementById('tab-bar-container'),
			() => {
				this._update();
			}
		);
	};

	this.getState = function () {
		return this._tabs.map((tab) => {
			let type = tab.type;
			if (type === 'reader-unloaded') {
				type = 'reader';
			}
			var o = {
				type,
				title: tab.title,
				timeUnselected: tab.timeUnselected
			};
			if (tab.data) {
				o.data = tab.data;
			}
			if (tab.id == this._selectedID) {
				o.selected = true;
			}
			return o;
		});
	};

	this.restoreState = function (tabs) {
		for (let i = 0; i < tabs.length; i++) {
			let tab = tabs[i];
			if (tab.type === 'library') {
				this.rename('zotero-pane', tab.title);
			}
			else if (tab.type === 'reader') {
				if (Zotero.Items.exists(tab.data.itemID)) {
					if (tab.selected) {
						Zotero.Reader.open(tab.data.itemID,
							null,
							{
								title: tab.title,
								tabIndex: i,
								openInBackground: !tab.selected,
								secondViewState: tab.data.secondViewState
							}
						);
					}
					else {
						this.add({
							type: 'reader-unloaded',
							title: tab.title,
							index: i,
							data: tab.data
						});
					}
				}
			}
		}
		// Unset the previously selected tab id, because it was set when restoring tabs
		this._prevSelectedID = null;
	};
	
	/**
	 * Add a new tab
	 *
	 * @param {String} type
	 * @param {String} title
	 * @param {String} data - Extra data about the tab to pass to notifier and session
	 * @param {Integer} index
	 * @param {Boolean} select
	 * @param {Function} onClose
	 * @return {{ id: string, container: XULElement}} id - tab id, container - a new tab container created in the deck
	 */
	this.add = function ({ id, type, data, title, index, select, onClose, preventJumpback }) {
		if (typeof type != 'string') {
		}
		if (typeof title != 'string') {
			throw new Error(`'title' should be a string (was ${typeof title})`);
		}
		if (index !== undefined && (!Number.isInteger(index) || index < 1)) {
			throw new Error(`'index' should be an integer > 0 (was ${index} (${typeof index})`);
		}
		if (onClose !== undefined && typeof onClose != 'function') {
			throw new Error(`'onClose' should be a function (was ${typeof onClose})`);
		}
		id = id || 'tab-' + Zotero.Utilities.randomString();
		var container = document.createXULElement('vbox');
		container.id = id;
		this.deck.appendChild(container);
		var tab = { id, type, title, data, onClose };
		index = index || this._tabs.length;
		this._tabs.splice(index, 0, tab);
		this._update();
		Zotero.Notifier.trigger('add', 'tab', [id], { [id]: Object.assign({}, data, { type }) }, true);
		if (select) {
			let previousID = this._selectedID;
			this.select(id);
			if (!preventJumpback) {
				this._prevSelectedID = previousID;
			}
		}
		return { id, container };
	};

	/**
	 * Set a new tab title
	 *
	 * @param {String} id
	 * @param {String} title
	 */
	this.rename = function (id, title) {
		if (typeof title != 'string') {
			throw new Error(`'title' should be a string (was ${typeof title})`);
		}
		var { tab } = this._getTab(id);
		if (!tab) {
			return;
		}
		tab.title = title;
		this._update();
	};

	/**
	 * Close tabs
	 *
	 * @param {String|Array<String>|undefined} ids One or more ids, or empty for the current tab
	 */
	this.close = function (ids) {
		if (!ids) {
			ids = [this._selectedID];
		}
		else if (!Array.isArray(ids)) {
			ids = [ids];
		}
		if (ids.includes('zotero-pane')) {
			throw new Error('Library tab cannot be closed');
		}
		var historyEntry = [];
		var closedIDs = [];
		var tmpTabs = this._tabs.slice();
		for (var id of ids) {
			let { tab, tabIndex } = this._getTab(id);
			if (!tab) {
				continue;
			}
			if (tab.id == this._selectedID) {
				this.select(this._prevSelectedID || (this._tabs[tabIndex + 1] || this._tabs[tabIndex - 1]).id);
			}
			if (tab.id == this._prevSelectedID) {
				this._prevSelectedID = null;
			}
			tabIndex = this._tabs.findIndex(x => x.id === id);
			this._tabs.splice(tabIndex, 1);
			if (tab.onClose) {
				tab.onClose();
			}
			historyEntry.push({ index: tmpTabs.indexOf(tab), data: tab.data });
			closedIDs.push(id);

			setTimeout(() => {
				document.getElementById(tab.id).remove();
				// For unknown reason fx102, unlike 60, sometimes doesn't automatically update selected index
				let selectedIndex = Array.from(this.deck.children).findIndex(x => x.id == this._selectedID);
				if (this.deck.selectedIndex !== selectedIndex) {
					this.deck.selectedIndex = selectedIndex;
				}
			});
		}
		this._history.push(historyEntry);
		Zotero.Notifier.trigger('close', 'tab', [closedIDs], true);
		this._update();
	};

	/**
	 * Close all tabs except the first one
	 */
	this.closeAll = function () {
		this.close(this._tabs.slice(1).map(x => x.id));
	};
	
	/**
	 * Undo tabs closing
	 */
	this.undoClose = async function () {
		var historyEntry = this._history.pop();
		if (historyEntry) {
			let maxIndex = -1;
			let openPromises = [];
			for (let tab of historyEntry) {
				if (Zotero.Items.exists(tab.data.itemID)) {
					openPromises.push(Zotero.Reader.open(tab.data.itemID,
						null,
						{
							tabIndex: tab.index,
							openInBackground: true
						}
					));
					if (tab.index > maxIndex) {
						maxIndex = tab.index;
					}
				}
			}
			await Promise.all(openPromises);
			// Select last reopened tab
			if (maxIndex > -1) {
				this.jump(maxIndex);
			}
		}
	};

	/**
	 * Move a tab to the specified index
	 *
	 * @param {String} id
	 * @param {Integer} newIndex
	 */
	this.move = function (id, newIndex) {
		if (!Number.isInteger(newIndex) || newIndex < 1) {
			throw new Error(`'newIndex' should be an interger > 0 (was ${newIndex} (${typeof newIndex})`);
		}
		var { tab, tabIndex } = this._getTab(id);
		if (tabIndex == 0) {
			throw new Error('Library tab cannot be moved');
		}
		if (!tab || tabIndex == newIndex) {
			return;
		}
		if (newIndex > tabIndex) {
			newIndex--;
		}
		this._tabs.splice(tabIndex, 1);
		this._tabs.splice(newIndex, 0, tab);
		this._update();
	};

	/**
	 * Select a tab
	 *
	 * @param {String} id
	 * @param {Boolean} reopening
	 */
	this.select = function (id, reopening, options = {}) {
		var { tab, tabIndex } = this._getTab(id);
		// Move focus to the last focused element of zoteroPane if any or itemTree otherwise
		let focusZoteroPane = () => {
			if (tab.id !== 'zotero-pane') return;
			if (options.focusElementID) {
				tab.lastFocusedElement = document.getElementById(options.focusElementID);
			}
			// Small delay to make sure the focus does not remain on the actual
			// tab after mouse click
			setTimeout(() => {
				if (tab.lastFocusedElement) {
					tab.lastFocusedElement.focus();
				}
				if (document.activeElement !== tab.lastFocusedElement) {
					ZoteroPane_Local.itemsView.focus();
				}
				tab.lastFocusedElement = null;
			});
		};
		if (!tab || tab.id === this._selectedID) {
			// Focus on reader or zotero pane when keepTabFocused is explicitly false
			// E.g. when a tab is selected via Space or Enter
			if (options.keepTabFocused === false && tab?.id === this._selectedID) {
				var reader = Zotero.Reader.getByTabID(this._selectedID);
				if (reader) {
					reader.focus();
				}
				if (tab.id == 'zotero-pane') {
					focusZoteroPane();
				}
			}
			return;
		}
		let selectedTab;
		if (this._selectedID) {
			selectedTab = this._getTab(this._selectedID).tab;
			if (selectedTab) {
				selectedTab.timeUnselected = Zotero.Date.getUnixTimestamp();
			}
		}
		
		// If the last focus data was recorded for a different item, discard it
		if (!this._focusOptions.itemID || this._focusOptions.itemID != tab?.data?.itemID) {
			this._focusOptions = {};
		}
		// Save focus option for this item to tell reader and contextPane how to handle focus
		if (Object.keys(options).length && selectedTab) {
			this._focusOptions.keepTabFocused = !!options.keepTabFocused;
			this._focusOptions.itemID = tab?.data?.itemID;
		}
		if (this._selectedID === 'zotero-pane'
		&& !document.activeElement.classList.contains("tab")
		&& document.activeElement.tagName !== 'window') {
			// never return focus to another tab or <window>
			selectedTab.lastFocusedElement = document.activeElement;
		}
		if (tab.type === 'reader-unloaded') {
			this.close(tab.id);
			Zotero.Reader.open(tab.data.itemID, options && options.location, {
				tabID: tab.id,
				title: tab.title,
				tabIndex,
				allowDuplicate: true,
				secondViewState: tab.data.secondViewState,
				preventJumpback: true
			});
			return;
		}
		this._prevSelectedID = reopening ? this._selectedID : null;
		this._selectedID = id;
		this.deck.selectedIndex = Array.from(this.deck.children).findIndex(x => x.id == id);
		this._update();
		Zotero.Notifier.trigger('select', 'tab', [tab.id], { [tab.id]: { type: tab.type } }, true);
		if (tab.id === 'zotero-pane' && (options.keepTabFocused !== true)) {
			focusZoteroPane();
		}
		let tabNode = document.querySelector(`.tab[data-id="${tab.id}"]`);
		if (this._focusOptions.keepTabFocused && document.activeElement.getAttribute('data-id') != tabNode.getAttribute('data-id')) {
			// Keep focus on the currently selected tab during keyboard navigation
			if (tab.id == 'zotero-pane') {
				// Since there is more than one zotero-pane tab (pinned and not pinned),
				// use moveFocus() to focus on the visible one
				this.moveFocus('first');
			}
			else {
				tabNode.focus();
			}
		}
		// Allow React to create a tab node
		setTimeout(() => {
			tabNode.scrollIntoView({ behavior: 'smooth' });
		});
		// Border is not included when scrolling element node into view, therefore we do it manually.
		// TODO: `scroll-padding` since Firefox 68 can probably be used instead
		setTimeout(() => {
			if (!tabNode) {
				return;
			}
			let tabsContainerNode = document.querySelector('#tab-bar-container .tabs');
			if (tabNode.offsetLeft + tabNode.offsetWidth - tabsContainerNode.offsetWidth + 1 >= tabsContainerNode.scrollLeft) {
				document.querySelector('#tab-bar-container .tabs').scrollLeft += 1;
			}
			else if (tabNode.offsetLeft - 1 <= tabsContainerNode.scrollLeft) {
				document.querySelector('#tab-bar-container .tabs').scrollLeft -= 1;
			}
		}, 500);
		tab.timeSelected = Zotero.Date.getUnixTimestamp();
		// Without `setTimeout` the tab closing that happens in `unloadUnusedTabs` results in
		// tabs deck selection index bigger than the deck children count. It feels like something
		// isn't update synchronously
		setTimeout(() => this.unloadUnusedTabs());
	};

	this.unload = function (id) {
		var { tab, tabIndex } = this._getTab(id);
		if (!tab || tab.id === this._selectedID || tab.type !== 'reader') {
			return;
		}
		this.close(tab.id);
		this.add({
			id: tab.id,
			type: 'reader-unloaded',
			title: tab.title,
			index: tabIndex,
			data: tab.data
		});
	};

	this.unloadUnusedTabs = function () {
		for (let tab of this._tabs) {
			if (Zotero.Date.getUnixTimestamp() - tab.timeUnselected > UNLOAD_UNUSED_AFTER) {
				this.unload(tab.id);
			}
		}
		let tabs = this._tabs.slice().filter(x => x.type === 'reader');
		tabs.sort((a, b) => b.timeUnselected - a.timeUnselected);
		tabs = tabs.slice(MAX_LOADED_TABS);
		for (let tab of tabs) {
			this.unload(tab.id);
		}
	};

	/**
	 * Select the previous tab (closer to the library tab)
	 */
	this.selectPrev = function (options) {
		var { tabIndex } = this._getTab(this._selectedID);
		this.select((this._tabs[tabIndex - 1] || this._tabs[this._tabs.length - 1]).id, false, options || {});
	};

	/**
	 * Select the next tab (farther to the library tab)
	 */
	this.selectNext = function (options) {
		var { tabIndex } = this._getTab(this._selectedID);
		this.select((this._tabs[tabIndex + 1] || this._tabs[0]).id, false, options || {});
	};
	
	/**
	 * Select the last tab
	 */
	this.selectLast = function () {
		this.select(this._tabs[this._tabs.length - 1].id);
	};

	/**
	 * Return focus into the reader of the selected tab.
	 * Required to move focus from the tab into the reader after drag.
	 */
	this.refocusReader = function () {
		var reader = Zotero.Reader.getByTabID(this._selectedID);
		if (!reader) return;
		setTimeout(() => {
			reader.focus();
		});
	}

	/**
	 * Moves focus to a tab in the specified direction.
	 * @param {String} direction. "first", "last", "left", "right", or "current"
	 * If document.activeElement is a tab, "left" or "right" direction moves focus from that tab.
	 * Otherwise, focus is moved in the given direction from the currently selected tab.
	 */
	this.moveFocus = function (direction) {
		let focusedTabID = document.activeElement.getAttribute('data-id');
		var { tabIndex } = this._getTab(this._selectedID);

		let tabIndexToFocus = null;
	
		if (direction === "last") {
			tabIndexToFocus = this._tabs.length - 1;
		}
		else if (direction == "first") {
			tabIndexToFocus = 0;
		}
		else if (direction == "current") {
			tabIndexToFocus = tabIndex;
		}
		else {
			let focusedTabIndex = this._tabs.findIndex(tab => tab.id === focusedTabID);
			
			// If the currently focused element is not a tab, use tab that is selected
			if (focusedTabIndex === -1) {
				focusedTabIndex = tabIndex;
			}
	
			switch (direction) {
				case "left":
					tabIndexToFocus = focusedTabIndex > 0 ? focusedTabIndex - 1 : null;
					break;
				case "right":
					tabIndexToFocus = focusedTabIndex < this._tabs.length - 1 ? focusedTabIndex + 1 : null;
					break;
				default:
					throw new Error(`${direction} is an invalid direction.`);
			}
		}
	
		if (tabIndexToFocus !== null) {
			const nextTab = this._tabs[tabIndexToFocus];
			// There may be duplicate tabs - in normal tab array and in pinned tabs
			// So to get the right one, fetch all tabs with a given id and filter out one
			// that's visible
			let candidates = document.querySelectorAll(`[data-id="${nextTab.id}"]`);
			for (let node of candidates) {
				if (node.offsetParent) {
					node.focus();
					return;
				}
			}
		}
	};
	
	/**
	 * Jump to the tab at a particular index. If the index points beyond the array, jump to the last
	 * tab.
	 *
	 * @param {Integer} index
	 */
	this.jump = function (index) {
		this.select(this._tabs[Math.min(index, this._tabs.length - 1)].id);
	};

	this._openMenu = function (x, y, id) {
		var { tab, tabIndex } = this._getTab(id);
		let menuitem;
		let popup = document.createXULElement('menupopup');
		document.querySelector('popupset').appendChild(popup);
		popup.addEventListener('popuphidden', function (event) {
			if (event.target === popup) {
				popup.remove();
			}
		});
		if (id !== 'zotero-pane') {
			// Show in library
			menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', Zotero.getString('general.showInLibrary'));
			menuitem.addEventListener('command', () => {
				let { tab } = this._getTab(id);
				if (tab && (tab.type === 'reader' || tab.type === 'reader-unloaded')) {
					let itemID = tab.data.itemID;
					let item = Zotero.Items.get(itemID);
					if (item && item.parentItemID) {
						itemID = item.parentItemID;
					}
					ZoteroPane_Local.selectItem(itemID);
				}
			});
			popup.appendChild(menuitem);
			// Move tab
			let menu = document.createXULElement('menu');
			menu.setAttribute('label', Zotero.getString('tabs.move'));
			let menupopup = document.createXULElement('menupopup');
			menu.append(menupopup);
			popup.appendChild(menu);
			// Move to start
			menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', Zotero.getString('tabs.moveToStart'));
			menuitem.setAttribute('disabled', tabIndex == 1);
			menuitem.addEventListener('command', () => {
				this.move(id, 1);
			});
			menupopup.appendChild(menuitem);
			// Move to end
			menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', Zotero.getString('tabs.moveToEnd'));
			menuitem.setAttribute('disabled', tabIndex == this._tabs.length - 1);
			menuitem.addEventListener('command', () => {
				this.move(id, this._tabs.length);
			});
			menupopup.appendChild(menuitem);
			// Move to new window
			menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', Zotero.getString('tabs.moveToWindow'));
			menuitem.setAttribute('disabled', false);
			menuitem.addEventListener('command', () => {
				let { tab } = this._getTab(id);
				if (tab && (tab.type === 'reader' || tab.type === 'reader-unloaded')) {
					this.close(id);
					let { itemID, secondViewState } = tab.data;
					Zotero.Reader.open(itemID, null, { openInWindow: true, secondViewState });
				}
			});
			menupopup.appendChild(menuitem);
			// Duplicate tab
			menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', Zotero.getString('tabs.duplicate'));
			menuitem.addEventListener('command', () => {
				if (tab.data.itemID) {
					tabIndex++;
					let { secondViewState } = tab.data;
					Zotero.Reader.open(tab.data.itemID, null, { tabIndex, allowDuplicate: true, secondViewState });
				}
			});
			popup.appendChild(menuitem);
			// Separator
			popup.appendChild(document.createXULElement('menuseparator'));
		}
		// Close
		if (id != 'zotero-pane') {
			menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', Zotero.getString('general.close'));
			menuitem.addEventListener('command', () => {
				this.close(id);
			});
			popup.appendChild(menuitem);
		}
		// Close other tabs
		if (!(this._tabs.length == 2 && id != 'zotero-pane')) {
			menuitem = document.createXULElement('menuitem');
			menuitem.setAttribute('label', Zotero.getString('tabs.closeOther'));
			menuitem.addEventListener('command', () => {
				this.close(this._tabs.slice(1).filter(x => x.id != id).map(x => x.id));
			});
			popup.appendChild(menuitem);
		}
		// Undo close
		menuitem = document.createXULElement('menuitem');
		menuitem.setAttribute(
			'label',
			Zotero.getString(
				'tabs.undoClose',
				[],
				// If not disabled, show proper plural for tabs to reopen
				this._history.length ? this._history[this._history.length - 1].length : 1
			)
		);
		menuitem.setAttribute('disabled', !this._history.length);
		menuitem.addEventListener('command', () => {
			this.undoClose();
		});
		popup.appendChild(menuitem);
		popup.openPopupAtScreen(x, y, true);
	};

	// Used to move focus back to itemTree or contextPane from the tabs.
	this.focusWrapAround = function () {
		// Focus the last field of contextPane when reader is opened
		if (Zotero_Tabs.selectedIndex > 0) {
			Services.focus.moveFocus(window, document.getElementById("zotero-context-pane-sidenav"),
				Services.focus.MOVEFOCUS_BACKWARD, 0);
			return;
		}
		// If no item is selected, focus items list.
		if (ZoteroPane.itemPane.mode == "message") {
			document.getElementById("item-tree-main-default").focus();
			return;
		}
		let selected = ZoteroPane.getSelectedItems();
		// If the selected collection row is duplicates, just focus on the
		// itemTree until the merge pane is keyboard accessible
		// If multiple items selected, focus on itemTree as well.
		let collectionRow = ZoteroPane.collectionsView.selectedTreeRow;
		if (collectionRow.isDuplicates() || selected.length !== 1) {
			document.getElementById("item-tree-main-default").focus();
			return;
		}
		// Special treatment for notes and attachments in itemPane
		selected = selected[0];
		if (selected.isNote()) {
			document.getElementById("zotero-note-editor").focus();
			return;
		}
		if (selected.isAttachment()) {
			document.getElementById("attachment-note-editor").focus();
			return;
		}
		// For regular items, focus the last field
		// We do that by moving focus backwards from the element following the pane, because Services.focus doesn't
		// support MOVEFOCUS_LAST on subtrees
		Services.focus.moveFocus(window, document.getElementById("zotero-context-splitter"),
			Services.focus.MOVEFOCUS_BACKWARD, 0);
	};

	/**
	 * @param {title} String - Tab's title
	 * @returns <description> with bold substrings of title matching this._tabsMenuFilter
	 */
	let createTabsMenuLabel = (title) => {
		let desc = document.createElement('label');
		let regex = new RegExp(`(${Zotero.Utilities.quotemeta(this._tabsMenuFilter)})`, 'gi');
		let matches = title.matchAll(regex);

		let lastIndex = 0;

		for (let match of matches) {
			if (match.index > lastIndex) {
				// Add preceding text
				desc.appendChild(document.createTextNode(title.substring(lastIndex, match.index)));
			}
			// Add matched text wrapped in <b>
			
			if (match[0]) {
				let b = document.createElement('b');
				b.textContent = match[0];
				desc.appendChild(b);
			}
			lastIndex = match.index + match[0].length;
		}

		if (lastIndex < title.length) {
			// Add remaining text
			desc.appendChild(document.createTextNode(title.substring(lastIndex)));
		}
		return desc;
	};

	this.isTabsMenuVisible = () => {
		return ['showing', 'open'].includes(this.tabsMenuPanel.state);
	};

	/**
	 * Create the list of opened tabs in tabs menu.
	 */
	this.refreshTabsMenuList = () => {
		// Empty existing nodes
		this.tabsMenuList.replaceChildren();
		this._tabsMenuFocusedIndex = 0;
		let index = 1;
		for (let tab of this._tabs) {
			// Skip tabs whose title wasn't added yet
			if (tab.title == "") {
				continue;
			}
			// Filter tabs that do not match the filter
			if (!tab.title.toLowerCase().includes(this._tabsMenuFilter)) {
				continue;
			}
			// Top-level entry of the opened tabs array
			let row = document.createElement('div');
			let rowIndex = this._tabs.findIndex(x => x.id === tab.id);
			row.classList = "row";
			row.setAttribute("index", rowIndex);
			row.setAttribute("draggable", true);

			// Title of the tab
			let tabName = document.createElement('div');
			tabName.setAttribute('flex', '1');
			tabName.setAttribute('class', 'zotero-tabs-menu-entry title');
			tabName.setAttribute('tabindex', `${index++}`);
			tabName.setAttribute('aria-label', tab.title);
			tabName.setAttribute('title', tab.title);

			// Cross button to close a tab
			let closeButton = document.createElement('div');
			closeButton.className = "zotero-tabs-menu-entry close";
			let closeIcon = document.createElement('span');
			closeIcon.setAttribute('class', 'icon icon-css icon-x-8 icon-16');
			closeButton.setAttribute('data-l10n-id', 'zotero-tabs-menu-close-button');
			closeButton.appendChild(closeIcon);
			closeButton.addEventListener("click", () => {
				// Keep the focus on the cross at the same spot
				if (this._tabsMenuFocusedIndex == this.tabsMenuList.childElementCount * 2) {
					this._tabsMenuFocusedIndex = Math.max(this._tabsMenuFocusedIndex - 2, 0);
				}
				this.close(tab.id);
			});

			// Library tab has no close button
			if (tab.id == "zotero-pane") {
				closeButton.hidden = true;
			}

			closeButton.setAttribute('tabindex', `${index++}`);

			// Item type icon
			let span = document.createElement("span");
			span.className = "icon icon-css tab-icon";
			if (tab.id == 'zotero-pane') {
				// Determine which icon from the collection view rows to use (same as in _update())
				let index = ZoteroPane.collectionsView?.selection?.focused;
				if (typeof index !== 'undefined' && ZoteroPane.collectionsView.getRow(index)) {
					let iconName = ZoteroPane.collectionsView.getIconName(index);
					span.classList.add(`icon-${iconName}`);
				}
			}
			else {
				span.classList.add("icon-item-type");
				let item = Zotero.Items.get(tab.data.itemID);
				let dataTypeLabel = item.getItemTypeIconName(true);
				span.setAttribute("data-item-type", dataTypeLabel);
			}

			tabName.appendChild(span);
			// Actual label with bolded substrings matching the filter
			let tabLabel = createTabsMenuLabel(tab.title, this._tabsMenuFilter);
			tabName.appendChild(tabLabel);

			// Selected tab is bold
			if (tab.id == this._selectedID) {
				tabName.classList.add('selected');
			}
			// Onclick, go to selected tab + close popup
			tabName.addEventListener("click", () => {
				this.tabsMenuPanel.hidePopup();
				this.select(tab.id);
			});

			row.appendChild(tabName);
			row.appendChild(closeButton);
	
			row.addEventListener("dragstart", (e) => {
				// No drag-drop on the cross button or the library tab
				if (tab.id == 'zotero-pane' || e.target.classList.contains("close")) {
					e.preventDefault();
					e.stopPropagation();
					return;
				}
				e.dataTransfer.setData('zotero/tab', tab.id);
				setTimeout(() => {
					row.classList.remove("hover");
					row.setAttribute("id", "zotero-tabs-menu-dragged");
				});
			});
			

			row.addEventListener('dragover', (e) => {
				e.preventDefault();
				let tabId = e.dataTransfer.getData("zotero/tab");
				if (!tabId || tab.id == "zotero-pane") {
					return false;
				}
				if (row.getAttribute("id") == "zotero-tabs-menu-dragged") {
					return true;
				}
				let placeholder = document.getElementById("zotero-tabs-menu-dragged");
				if (row.previousSibling?.id == placeholder.id) {
					// If the placeholder exists before the row, swap the placeholder and the row
					row.parentNode.insertBefore(row, placeholder);
					placeholder.setAttribute("index", parseInt(row.getAttribute("index")) + 1);
				}
				else {
					// Insert placeholder before the row
					row.parentNode.insertBefore(placeholder, row);
					placeholder.setAttribute("index", parseInt(row.getAttribute("index")));
				}
				return false;
			});

			row.addEventListener('drop', (e) => {
				let tabId = e.dataTransfer.getData("zotero/tab");
				let rowIndex = parseInt(row.getAttribute("index"));
				if (rowIndex == 0) return;
				this.move(tabId, rowIndex);
			});

			row.addEventListener('dragend', (_) => {
				// If this.move() wasn't called, just re-render the menu
				if (document.getElementById("zotero-tabs-menu-dragged")) {
					this.refreshTabsMenuList();
				}
			});
			this.tabsMenuList.appendChild(row);
		}
	};

	this.showTabsMenu = function (button) {
		this.tabsMenuPanel.openPopup(button, "after_start", -20, -2, false, false);
	};

	this.handleTabsMenuHiding = function (event) {
		if (event.originalTarget.id != 'zotero-tabs-menu-panel') return;

		// Empty out the filter input field
		let menuFilter = document.getElementById('zotero-tabs-menu-filter');
		menuFilter.value = "";
		this._tabsMenuFilter = "";
		this.tabsMenuList.closest("panel").style.removeProperty('max-height');
	};

	this.handleTabsMenuShown = function (_) {
		focusTabsMenuEntry(0);
	};

	this.handleTabsMenuShowing = function (_) {
		this.refreshTabsMenuList();

		// Make sure that if the menu is very long, there is a small
		// gap left between the top/bottom of the menu and the edge of the screen
		let valuesAreWithinMargin = (valueOne, valueTwo, margin) => {
			return Math.abs(valueOne - valueTwo) <= margin;
		};
		let panel = document.getElementById("zotero-tabs-menu-panel");
		let panelRect = panel.getBoundingClientRect();
		const gapBeforeScreenEdge = 25;
		let absoluteTabsMenuTop = window.screenY - panelRect.height + panelRect.bottom;
		let absoluteTabsMenuBottom = window.screenY + panelRect.height + panelRect.top;

		// On windows, getBoundingClientRect does not give us correct top and bottom values
		// until popupshown, so instead use the anchor's position
		if (Zotero.isWin) {
			let anchor = document.getElementById("zotero-tb-tabs-menu");
			let anchorRect = anchor.getBoundingClientRect();
			absoluteTabsMenuTop = window.screenY - panelRect.height + anchorRect.top;
			absoluteTabsMenuBottom = window.screenY + panelRect.height + anchorRect.bottom;
		}
		// screen.availTop is not always right on Linux, so ignore it
		let availableTop = Zotero.isLinux ? 0 : screen.availTop;

		// Check if the end of the tabs menu is close to the edge of the screen
		let atTopScreenEdge = valuesAreWithinMargin(absoluteTabsMenuTop, availableTop, gapBeforeScreenEdge);
		let atBottomScreenEdge = valuesAreWithinMargin(absoluteTabsMenuBottom, screen.availHeight + availableTop, gapBeforeScreenEdge);

		let gap;
		// Limit max height of the menu to leave the specified gap till the screen's edge.
		// Due to screen.availTop behavior on linux, the menu can go outside of what is supposed
		// to be the available screen area, so special treatment for those edge cases.
		if (atTopScreenEdge || (Zotero.isLinux && absoluteTabsMenuTop < 0)) {
			gap = gapBeforeScreenEdge - (absoluteTabsMenuTop - availableTop);
		}
		if (atBottomScreenEdge || (Zotero.isLinux && absoluteTabsMenuBottom > screen.availHeight)) {
			gap = gapBeforeScreenEdge - (screen.availHeight + availableTop - absoluteTabsMenuBottom);
		}
		if (gap) {
			panel.style.maxHeight = `${panelRect.height - gap}px`;
		}
		// Try to scroll selected tab into the center
		let selectedTab = this.tabsMenuList.querySelector(".selected");
		if (selectedTab) {
			selectedTab.scrollIntoView({ block: 'center' });
		}
	};

	/**
	 * Record the value of the filter
	 */
	this.handleTabsMenuFilterInput = function (_, input) {
		if (this._tabsMenuFilter == input.value.toLowerCase()) {
			return;
		}
		this._tabsMenuFilter = input.value.toLowerCase();
		this.refreshTabsMenuList();
	};

	this.resetFocusIndex = (_) => {
		this._tabsMenuFocusedIndex = 0;
	};


	/**
	 * Focus on the element in the tabs menu with [tabindex=tabIndex] if given
	 * or [tabindex=this._tabsMenuFocusedIndex] otherwise
	 */
	let focusTabsMenuEntry = (tabIndex = null) => {
		tabIndex = tabIndex !== null ? tabIndex : this._tabsMenuFocusedIndex;
		if (tabIndex === null) {
			return;
		}
		var nextTab = this.tabsMenuList.parentElement.querySelector(`[tabindex="${tabIndex}"]`);
		if (!nextTab) {
			return;
		}
		this._tabsMenuIgnoreMouseover = true;
		this._tabsMenuFocusedIndex = tabIndex;
		let hovered = this.tabsMenuList.querySelector(".hover");
		if (hovered) {
			hovered.classList.remove("hover");
		}
		nextTab.focus();
		// For some reason (likely a mozilla bug),
		// a mouseover event fires at the location where the drag event started after the drop.
		// To not mark the wrong entry as hovered, ignore mouseover events for a bit after the focus change
		setTimeout(() => {
			this._tabsMenuIgnoreMouseover = false;
		}, 250);
	};

	/**
	 * Keyboard navigation within the tabs menu
	 * - Tab/Shift-Tab moves focus from the input field across tab titles and close buttons
	 * - Enter from the input field focuses the first tab
	 * - Enter on a toolbarbutton clicks it
	 * - ArrowUp/ArrowDown on a toolbarbutton moves focus to the next/previous toolbarbutton of the
	 *   same type (e.g. arrowDown from title focuses the next title)
	 * - ArrowUp from the first tab or ArrowDown from the last tab focuses the filter field
	 * - ArrowDown from the filter field focuses the first tab
	 * - ArrowUp from the filter field focuses the last tab
	 * - Home/PageUp focuses the filter field
	 * - End/PageDown focues the last tab title
	 * - CMD-f will focus the input field
	 */
	this.handleTabsMenuKeyPress = function (event) {
		let tabindex = this._tabsMenuFocusedIndex;
		if (event.key == "Tab") {
			event.preventDefault();
			let isShift = event.shiftKey;
			let moveTabIndex = () => tabindex++;
			if (isShift) {
				moveTabIndex = () => tabindex--;
			}
			moveTabIndex();
			let candidate = this.tabsMenuList.parentElement.querySelector(`[tabindex="${tabindex}"]`);
			// If the candidate is hidden (e.g. close button of library tab), skip it
			if (candidate && candidate.hidden) {
				moveTabIndex();
			}
			focusTabsMenuEntry(tabindex);
		}
		else if (["Home", "PageUp"].includes(event.key)) {
			event.preventDefault();
			focusTabsMenuEntry(0);
		}
		else if (["End", "PageDown"].includes(event.key)) {
			event.preventDefault();
			focusTabsMenuEntry(this.tabsMenuList.childElementCount * 2 - 1);
		}
		else if (["ArrowUp", "ArrowDown"].includes(event.key)) {
			event.preventDefault();
			let isFirstRow = tabindex <= 2 && tabindex > 0;
			// Step over 1 index to jump over close button, unless we move
			// from the filter field
			let step = tabindex == 0 ? 1 : 2;
			if (event.key == "ArrowDown") {
				tabindex += step;
			}
			else {
				tabindex -= step;
			}
			// If the candidate is a disabled element (e.g. close button of the library tab),
			// move focus to the element before it
			let candidate = this.tabsMenuList.parentElement.querySelector(`[tabindex="${tabindex}"]`);
			if (candidate && candidate.disabled) {
				tabindex--;
			}
			if (tabindex <= 0) {
				// ArrowUp from the first tab or the first close button focuses the filter field.
				// ArrowUp from the filter field focuses the last tab
				if (isFirstRow) {
					tabindex = 0;
				}
				else {
					tabindex = this.tabsMenuList.childElementCount * 2 - 1;
				}
			}
			// ArrowDown from the bottom focuses the filter field
			if (tabindex > this.tabsMenuList.childElementCount * 2) {
				tabindex = 0;
			}
			focusTabsMenuEntry(tabindex);
		}
		else if (["Enter", " "].includes(event.key)) {
			event.preventDefault();
			if (event.target.id == "zotero-tabs-menu-filter") {
				focusTabsMenuEntry(1);
				return;
			}
			event.target.click();
		}
		else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
			event.preventDefault();
			event.stopPropagation();
		}
		else if (event.key == "f" && (Zotero.isMac ? event.metaKey : event.ctrlKey)) {
			focusTabsMenuEntry(0);
			event.preventDefault();
			event.stopPropagation();
		}
	};
};
