/*
# 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';

import EventListenerManager from '/extlib/EventListenerManager.js';
import TabFavIconHelper from '/extlib/TabFavIconHelper.js';

import {
  log as internalLogger,
  dumpTab,
  mapAndFilter,
  mapAndFilterUniq,
  toLines,
  sanitizeForHTMLText,
  sanitizeForRegExpSource,
  isNewTabCommandTab,
  isFirefoxViewTab,
  configs,
  doProgressively,
} from './common.js';

import * as ApiTabs from '/common/api-tabs.js';
import * as Constants from './constants.js';
import * as ContextualIdentities from './contextual-identities.js';
import * as SidebarConnection from './sidebar-connection.js';
import * as TabsStore from './tabs-store.js';
import * as UniqueId from './unique-id.js';

import Window from './Window.js';

function log(...args) {
  internalLogger('common/TreeItem', ...args);
}

function successorTabLog(...args) {
  internalLogger('background/successor-tab', ...args);
}


// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab
export const kPERMISSION_ACTIVE_TAB = 'activeTab';
export const kPERMISSION_TABS       = 'tabs';
export const kPERMISSION_COOKIES    = 'cookies';
export const kPERMISSION_INCOGNITO  = 'incognito'; // only for internal use
export const kPERMISSIONS_ALL = new Set([
  kPERMISSION_TABS,
  kPERMISSION_COOKIES,
  kPERMISSION_INCOGNITO
]);


const mOpenedResolvers            = new Map();

const mIncompletelyTrackedTabs = new Map();
const mMovingTabs              = new Map();
const mPromisedTrackedTabs     = new Map();


browser.windows.onRemoved.addListener(windowId => {
  mIncompletelyTrackedTabs.delete(windowId);
  mMovingTabs.delete(windowId);
});

export class TreeItem {
  static TYPE_TAB   = 'tab';
  static TYPE_GROUP = 'group';
  static TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER = 'group-collapsed-members-counter';

  static onElementBound = new EventListenerManager();

  // The list of properties which should be ignored when synchronization from the
  // background to sidebars.
  static UNSYNCHRONIZABLE_PROPERTIES = new Set([
    'id',
    // Ignore "index" on synchronization, because it maybe wrong for the sidebar.
    // Index of tabs are managed and fixed by other sections like handling of
    // "kCOMMAND_NOTIFY_TAB_CREATING", Window.prototype.trackTab, and others.
    // See also: https://github.com/piroor/treestyletab/issues/2119
    'index',
    'reindexedBy'
  ]);

  // key = addon ID
  // value = Set of states
  static autoStickyStates = new Map();
  static allAutoStickyStates = new Set();

  constructor(raw) {
    raw.$TST = this;
    this.raw = raw;
    this.id  = raw.id;

    raw.type = this.type || 'unknown';

    this.trackedAt = Date.now();
    this.opened = Promise.resolve(true);

    // We should not change the shape of the object, so temporary data should be held in this map.
    this.temporaryMetadata = new Map();

    this.highPriorityTooltipTexts = new Map();
    this.lowPriorityTooltipTexts  = new Map();

    this.$exportedForAPI = null;
    this.$exportedForAPIWithPermissions = new Map();

    this.element = null;
    this.classList = null;
    this.promisedElement = new Promise((resolve, _reject) => {
      this._promisedElementResolver = resolve;
    });

    this.states = new Set();
    this.clear();

    this.uniqueId = {
      id:            null,
      originalId:    null,
      originalTabId: null
    };
    this.promisedUniqueId = Promise.resolve(null);
  }

  destroy() {
    if (this.element &&
        this.element.parentNode)
      this.element.parentNode.removeChild(this.element);
    this.unbindElement();
    // this.raw.$TST = null; // raw.$TST is used by destruction processes.
    this.raw = null;
    this.promisedUniqueId = null;
    this.uniqueId = null;
    this.destroyed = true;
  }

  clear() {
    this.states.clear();
    this.attributes = {};
  }

  bindElement(element) {
    element.$TST   = this;
    element.apiRaw = this.raw;
    this.element = element;
    this.classList = element.classList;
    // wait until initialization processes are completed
    (Constants.IS_BACKGROUND ?
      setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document.
      window.requestAnimationFrame)(() => {
      this._promisedElementResolver(element);
      if (!element) { // reset for the next binding
        this.promisedElement = new Promise((resolve, _reject) => {
          this._promisedElementResolver = resolve;
        });
      }
      if (!this.raw) // unbound while waiting!
        return;
      TreeItem.onElementBound.dispatch(this.raw);
    }, 0);
  }

  unbindElement() {
    if (this.element) {
      for (const state of this.states) {
        this.element.classList.remove(state);
        if (state == Constants.kTAB_STATE_HIGHLIGHTED)
          this.element.removeAttribute('aria-selected');
      }
      for (const name of Object.keys(this.attributes)) {
        this.element.removeAttribute(name);
      }
      this.element.$TST = null;
      this.element.apiRaw = null;
    }
    this.element = null;
    this.classList = null;
  }

  startMoving() {
    return Promise.resolve();
  }

  updateUniqueId(_options = {}) {
    return Promise.resolve(null);
  }

  get type() {
    return null;
  }

  get renderingId() {
    return `${this.type}:${this.id}`;
  }

  get title() {
    return this.raw.title;
  }

  //===================================================================
  // status of tree item
  //===================================================================

  get collapsed() {
    return this.states.has(Constants.kTAB_STATE_COLLAPSED);
  }

  get collapsedCompletely() {
    return this.states.has(Constants.kTAB_STATE_COLLAPSED_DONE);
  }

  get subtreeCollapsed() {
    return this.states.has(Constants.kTAB_STATE_SUBTREE_COLLAPSED);
  }

  get isSubtreeCollapsable() {
    return this.hasChild &&
           !this.collapsed &&
           !this.subtreeCollapsed;
  }

  get isAutoExpandable() {
    return this.hasChild && this.subtreeCollapsed;
  }

  get duplicating() {
    return this.states.has(Constants.kTAB_STATE_DUPLICATING);
  }

  get removing() {
    return this.states.has(Constants.kTAB_STATE_REMOVING);
  }

  get sticky() {
    return this.states.has(Constants.kTAB_STATE_STICKY);
  }

  get stuck() {
    return this.element?.parentNode?.classList.contains('sticky-tabs-container');
  }

  get canBecomeSticky() {
    if (this.collapsed ||
        this.states.has(Constants.kTAB_STATE_EXPANDING) ||
        this.states.has(Constants.kTAB_STATE_COLLAPSING))
      return false;

    if (this.sticky)
      return true;

    if ((new Set([...this.states, ...TreeItem.allAutoStickyStates])).size < this.states.size + TreeItem.allAutoStickyStates.size) {
      return true;
    }

    return false;
  }

  get promisedPossibleOpenerBookmarks() {
    return Promise.resolve(null);
  }

  get defaultTooltipText() {
    return this.raw.title;
  }

  get tooltipTextWithDescendants() {
    const tooltip = [`* ${this.defaultTooltipText}`];
    for (const child of this.children) {
      if (!child)
        continue;
      tooltip.push(child.$TST.tooltipTextWithDescendants.replace(/^/gm, '  '));
    }
    return tooltip.join('\n');
  }

  get tooltipHtml() {
    return `<span class="title-line"
                 ><span class="title"
                       >${sanitizeForHTMLText(this.raw.title)}</span></span>`;
  }

  get tooltipHtmlWithDescendants() {
    return `<ul>${this.generateTooltipHtmlWithDescendants()}</ul>`;
  }
  generateTooltipHtmlWithDescendants() {
    let tooltip = `<li>${this.tooltipHtml}`;
    const children = [];
    for (const child of this.children) {
      if (!child)
        continue;
      children.push(child.$TST.generateTooltipHtmlWithDescendants());
    }
    if (children.length > 0)
      tooltip += `<ul>${children.join('')}</ul>`;
    return `${tooltip}</li>`;
  }

  registerTooltipText(ownerId, text, isHighPriority = false) {
    if (isHighPriority) {
      this.highPriorityTooltipTexts.set(ownerId, text);
      this.lowPriorityTooltipTexts.delete(ownerId);
    }
    else {
      this.highPriorityTooltipTexts.delete(ownerId);
      this.lowPriorityTooltipTexts.set(ownerId, text);
    }
  }

  unregisterTooltipText(ownerId) {
    this.highPriorityTooltipTexts.delete(ownerId);
    this.lowPriorityTooltipTexts.delete(ownerId);
  }

  get highPriorityTooltipText() {
    if (this.highPriorityTooltipTexts.size == 0)
      return null;
    return [...this.highPriorityTooltipTexts.values()][this.highPriorityTooltipTexts.size - 1];
  }

  get lowPriorityTooltipText() {
    if (this.lowPriorityTooltipTexts.size == 0)
      return null;
    return [...this.lowPriorityTooltipTexts.values()][this.lowPriorityTooltipTexts.size - 1];
  }

  //===================================================================
  // neighbor tabs
  //===================================================================

  get nextTab() { return null; }
  get previousTab() { return null; }
  get unsafeNextTab() { return null; }
  get unsafePreviousTab() { return null; }
  get nearestCompletelyOpenedNormalFollowingTab() { return null; }
  get nearestCompletelyOpenedNormalPrecedingTab() { return null; }
  get nearestVisibleFollowingTab() { return null; }
  get unsafeNearestExpandedFollowingTab() { return null; }
  get nearestVisiblePrecedingTab() { return null; }
  get unsafeNearestExpandedPrecedingTab() { return null; }
  get nearestLoadedTab() { return null; }
  get nearestLoadedTabInTree() { return null; }
  get nearestLoadedSiblingTab() { return null; }
  get nearestSameTypeRenderedTab() { return null; }

  //===================================================================
  // tree relations
  //===================================================================

  get parent() { return null; }
  get hasParent() { return false; }

  get ancestorIds() { return []; }
  get ancestors() { return []; }

  get level() { return 0; }

  get rootTab() { return null; }

  get topmostSubtreeCollapsedAncestor() { return null; }

  get nearestVisibleAncestorOrSelf() { return null; }

  get nearestFollowingRootTab() { return null; }

  get nearestFollowingForeignerTab() {
    const base = this.lastDescendant || this.raw;
    return base?.$TST.nextTab;
  }

  get unsafeNearestFollowingForeignerTab() {
    const base = this.lastDescendant || this.raw;
    return base?.$TST.unsafeNextTab;
  }

  get children() { return []; }

  get firstChild() {
    const children = this.children;
    return children.length > 0 ? children[0] : null ;
  }

  get firstVisibleChild() {
    const firstChild = this.firstChild;
    return firstChild && !firstChild.$TST.collapsed && !firstChild.hidden && firstChild;
  }

  get lastChild() {
    const children = this.children;
    return children.length > 0 ? children[children.length - 1] : null ;
  }

  get hasChild() { return false; }

  get descendants() { return []; }

  get lastDescendant() {
    const descendants = this.descendants;
    return descendants.length ? descendants[descendants.length-1] : null ;
  }

  get nextSiblingTab() { return null; }

  get nextVisibleSiblingTab() {
    const nextSibling = this.nextSiblingTab;
    return nextSibling && !nextSibling.$TST.collapsed && !nextSibling.hidden && nextSibling;
  }

  get previousSiblingTab() { return null; }

  get needToBeGroupedSiblings() { return []; }

  //===================================================================
  // other relations
  //===================================================================

  findSuccessor(_options = {}) {
    return null;
  }

  // if all items are aldeardy placed at there, we don't need to move them.
  isAllPlacedBeforeSelf(items) {
    if (!this.raw ||
        items.length == 0)
      return true;
    let nextItem = this.raw;
    if (items[items.length - 1] == nextItem)
      nextItem = nextItem.$TST.unsafeNextTab;
    if (!nextItem && !items[items.length - 1].$TST.unsafeNextTab)
      return true;

    items = Array.from(items);
    let previousItem = items.shift();
    for (const item of items) {
      if (item.$TST.unsafePreviousTab != previousItem)
        return false;
      previousItem = item;
    }
    return !nextItem ||
           !previousItem ||
           previousItem.$TST.unsafeNextTab == nextItem;
  }

  isAllPlacedAfterSelf(items) {
    if (!this.raw ||
        items.length == 0)
      return true;
    let previousItem = this.raw;
    if (items[0] == previousItem)
      previousItem = previousItem.$TST.unsafePreviousTab;
    if (!previousItem && !items[0].$TST.unsafePreviousTab)
      return true;

    items = Array.from(items).reverse();
    let nextItem = items.shift();
    for (const item of items) {
      if (item.$TST.unsafeNextTab != nextItem)
        return false;
      nextItem = item;
    }
    return !previousItem ||
           !nextItem ||
           nextItem.$TST.unsafePreviousTab == previousItem;
  }

  detach() {}

  //===================================================================
  // State
  //===================================================================

  async toggleState(state, condition, { permanently, toTab, broadcast } = {}) {
    if (condition)
      return this.addState(state, { permanently, toTab, broadcast });
    else
      return this.removeState(state, { permanently, toTab, broadcast });
  }

  async addState(state) {
    state = state && String(state) || undefined;
    if (!this.raw || !state)
      return;

    if (this.classList) {
      this.classList.add(state);
    }
    if (this.states) {
      this.states.add(state);
    }
  }

  async removeState(state) {
    state = state && String(state) || undefined;
    if (!this.raw || !state)
      return;

    if (this.classList) {
      this.classList.remove(state);
    }
    if (this.states) {
      this.states.delete(state);
    }
  }

  async getPermanentStates() {
    return Promise.resolve([]);
  }

  inheritSoundStateFromChildren() {}

  inheritSharingStateFromChildren() {}

  onNativeGroupModified() {}

  setAttribute(attribute, value) {
    if (this.element)
      this.element.setAttribute(attribute, value);
    this.attributes[attribute] = value;
  }

  getAttribute(attribute) {
    return this.attributes[attribute];
  }

  removeAttribute(attribute) {
    if (this.element)
      this.element.removeAttribute(attribute);
    delete this.attributes[attribute];
  }

  resolveOpened() {}
  rejectOpened() {}

  memorizeNeighbors(hint) {
    if (!this.raw) // already closed tab
      return;
    log(`memorizeNeighbors ${this.raw.id} as ${hint}`);
    this.lastPreviousTabId = this.unsafePreviousTab?.id;
    this.lastNextTabId = this.unsafeNextTab?.id;
  }

  // https://github.com/piroor/treestyletab/issues/2309#issuecomment-518583824
  get movedInBulk() {
    const previousTab = this.unsafePreviousTab;
    if (this.lastPreviousTabId &&
        this.lastPreviousTabId != previousTab?.id) {
      log(`not bulkMoved lastPreviousTabId=${this.lastNextTabId}, previousTab=${previousTab?.id}`);
      return false;
    }

    const nextTab = this.unsafeNextTab;
    if (this.lastNextTabId &&
        this.lastNextTabId != nextTab?.id) {
      log(`not bulkMoved lastNextTabId=${this.lastNextTabId}, nextTab=${nextTab?.id}`);
      return false;
    }

    return true;
  }

  get sanitized() {
    if (!this.raw)
      return {};

    const sanitized = {
      ...this.raw,
      '$possibleInitialUrl': null,
      '$TST': null,
      '$exportedForAPI': null,
      '$exportedForAPIWithPermissions': null,
    };
    delete sanitized.$TST;
    return sanitized;
  }

  export(full) {
    const exported = {
      id:         this.id,
      uniqueId:   this.uniqueId,
      states:     Array.from(this.states),
      attributes: this.attributes,
      parentId:   this.parentId,
      childIds:   this.childIds,
      collapsed:  this.collapsed,
      subtreeCollapsed: this.subtreeCollapsed
    };
    if (full)
      return {
        ...this.sanitized,
        $TST: exported
      };
    return exported;
  }

  apply(exported) {
    this.raw.title = exported.title;
  }

  // This function is complex a little, but we should not make a custom class for this purpose,
  // bacause instances of the class will be very short-life and increases RAM usage on
  // massive tabs case.
  async exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey } = {}) {
    const permissionsKey = [...permissions].sort().join(',');
    if (!light &&
        configs.cacheAPITreeItems &&
        this.$exportedForAPIWithPermissions.has(permissionsKey))
      return this.$exportedForAPIWithPermissions.get(permissionsKey);

    let exportedTreeItem = configs.cacheAPITreeItems && light ? this.$exportedForAPI : null;
    if (!exportedTreeItem) {
      const children = await doProgressively(
        this.raw.$TST.children,
        child => child.$TST.exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey }),
        interval
      );

      const tabStates = this.raw.$TST.states;
      exportedTreeItem = {
        id:             this.raw.id,
        windowId:       this.raw.windowId,
        type:           this.type,
        states:         tabStates && tabStates.size > 0 &&Constants.kTAB_SAFE_STATES_ARRAY.filter(state => tabStates.has(state)) || [],
        indent:         parseInt(this.raw.$TST.getAttribute(Constants.kLEVEL) || 0),
        children,
        ancestorTabIds: this.raw.$TST.ancestorIds || [],
        bundledTabId:   this.raw.$TST.bundledTabId,
      };
      if (this.stuck)
        exportedTreeItem.states.push(Constants.kTAB_STATE_STUCK);
      if (configs.cacheAPITreeItems && light)
        this.$exportedForAPI = exportedTreeItem;
    }

    if (light)
      return exportedTreeItem;

    const fullExportedTreeItem = { ...exportedTreeItem };

    await this.exportFullTreeItemProperties(fullExportedTreeItem, { isContextTab, interval, permissions, cache });

    if (configs.cacheAPITreeItems)
      this.$exportedForAPIWithPermissions.set(permissionsKey, fullExportedTreeItem)
    return fullExportedTreeItem;
  }

  exportFullTreeItemProperties() {}

  invalidateCache() {
    this.$exportedForAPI = null;
    this.$exportedForAPIWithPermissions.clear();
  }

  applyStatesToElement() {
    if (!this.element)
      return;

    this.applyAttributesToElement();

    for (const state of this.states) {
      this.element.classList.add(state);
    }

    for (const [name, value] of Object.entries(this.attributes)) {
      this.element.setAttribute(name, value);
    }
  }

  applyAttributesToElement() {
    if (!this.element)
      return;

    this.element.applyAttributes();
  }

  /* element utilities */

  invalidateElement(targets) {
    if (this.element?.invalidate)
      this.element.invalidate(targets);
  }

  updateElement(targets) {
    if (this.element?.update)
      this.element.update(targets);
  }


  //===================================================================
  // class methods
  //===================================================================

  static registerAutoStickyState(providerId, statesToAdd) {
    if (!statesToAdd) {
      statesToAdd = providerId;
      providerId = browser.runtime.id;
    }
    const states = TreeItem.autoStickyStates.get(providerId) || new Set();
    if (!Array.isArray(statesToAdd))
      statesToAdd = [statesToAdd];
    for (const state of statesToAdd) {
      states.add(state)
    }
    if (states.size == 0)
      return;

    TreeItem.autoStickyStates.set(providerId, states);
    for (const state of states) {
      TreeItem.allAutoStickyStates.add(state);
    }

    TreeItem.updateCanBecomeStickyTabsIndex(TabsStore.getCurrentWindowId());

    if (Constants.IS_BACKGROUND) {
      SidebarConnection.sendMessage({
        type: Constants.kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE,
        providerId,
        add:  [...statesToAdd],
      });
    }
  }

  static unregisterAutoStickyState(providerId, statesToRemove) {
    if (!statesToRemove) {
      statesToRemove = providerId;
      providerId = browser.runtime.id;
    }
    const states = TreeItem.autoStickyStates.get(providerId);
    if (!states)
      return;
    if (!Array.isArray(statesToRemove))
      statesToRemove = [statesToRemove];
    for (const state of statesToRemove) {
      states.delete(state)
    }
    if (states.size > 0)
      TreeItem.autoStickyStates.set(providerId, states);
    else
      TreeItem.autoStickyStates.delete(providerId);

    TreeItem.allAutoStickyStates = new Set([
      ...TreeItem.autoStickyStates.values(),
    ].flat());

    TreeItem.updateCanBecomeStickyTabsIndex(TabsStore.getCurrentWindowId());

    if (Constants.IS_BACKGROUND) {
      SidebarConnection.sendMessage({
        type:   Constants.kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE,
        providerId,
        remove: [...statesToRemove],
      });
    }
  }

  static async updateCanBecomeStickyTabsIndex(windowId) {
    const tabs = await (windowId ? browser.tabs.query({ windowId }) : browser.tabs.query({}));
    for (const tab of tabs) {
      const item = TreeItem.get(tab);
      if (!item) {
        continue;
      }
      if (item.$TST.canBecomeSticky)
        TabsStore.addCanBecomeStickyTab(item);
      else
        TabsStore.removeCanBecomeStickyTab(item);
    }
  }

  static uniqTabsAndDescendantsSet(tabs) {
    if (!Array.isArray(tabs))
      tabs = [tabs];
    return Array.from(new Set(tabs.map(tab => [tab].concat(tab.$TST.descendants)).flat())).sort(TreeItem.compare);
  }

  static compare(a, b) {
    const delta = a.index - b.index;
    if (delta == 0) {
      return (a.type == TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER) ? 1 :
        (a.type == TreeItem.TYPE_GROUP || !!a.color) ? -1 :
          1;
    }
    return delta;
  }

  static sort(tabs) {
    return tabs.length == 0 ? tabs : tabs.sort(TreeItem.compare);
  }
}


export class TabGroupCollapsedMembersCounter extends TreeItem {
  constructor(raw) {
    super(raw);

    raw.type = TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER;

    this.reindex();
  }

  destroy() {
    super.destroy();

    this.raw.group = null;
  }

  get type() {
    return TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER;
  }

  reindex(maybeLastMember) {
    const lastMember = this.raw.group.$TST.lastMember || maybeLastMember;
    if (lastMember) {
      this.raw.index = lastMember.index;
    }
  }

  get group() {
    return this.raw.group;
  }

  get nativeTabGroup() {
    return this.raw.group;
  }

  update() {
    this.raw.color = this.raw.group.color;
    this.raw.windowId = this.raw.group.windowId;
    this.reindex();
  }

  get title() {
    const collapsedItemsCount = Math.max(0, this.raw.group.$TST.members.length - 1);
    return `+${collapsedItemsCount}`;
  }

  get sanitized() {
    if (!this.raw)
      return {};

    const sanitized = {
      ...super.sanitized,
      group: this.raw.group.$TST.sanitized,
    };
    return sanitized;
  }

  export(full) {
    const exported = super.export(full);
    exported.group = this.raw.group.$TST.export(full);
    if (full)
      return {
        ...this.sanitized,
        $TST: exported
      };
    return exported;
  }
}


export class TabGroup extends TreeItem {
  constructor(raw) {
    super(raw);

    TabsStore.tabGroups.set(raw.id, raw);
    TabsStore.windows.get(raw.windowId)?.tabGroups.set(raw.id, raw);

    this.reindex();
  }

  destroy() {
    const win = TabsStore.windows.get(this.raw.windowId);
    if (win) {
      win.tabGroups.delete(this.id);
    }

    TabsStore.tabGroups.delete(this.id);

    if (this._collapsedMembersCounterItem) {
      this._collapsedMembersCounterItem.destroy();
      this._collapsedMembersCounterItem = null;
    }

    super.destroy();
  }

  get type() {
    return TreeItem.TYPE_GROUP;
  }

  get group() {
    return this.raw;
  }

  get nearestVisibleAncestorOrSelf() {
    return this.raw;
  }

  get members() {
    return TabGroup.getMembers(this.raw.id);
  }

  get firstMember() {
    return TabGroup.getFirstMember(this.raw.id);
  }

  get lastMember() {
    return TabGroup.getLastMember(this.raw.id);
  }

  get children() {
    return this.members.filter(tab => !tab.$TST.parentId);
  }

  get hasChild() {
    return !!this.firstMember;
  }

  get descendants() {
    return this.members;
  }

  reindex(maybeFirstMember) {
    const firstMember = TabGroup.getFirstMember(this.raw.id) || maybeFirstMember;
    if (firstMember) {
      this.raw.index = firstMember.index;
    }
  }

  apply(exported) {
    super.apply(exported);
    this.raw.color = exported.color;
    this.raw.collapsed = exported.collapsed;
  }

  get createParams() {
    return {
      title:     this.raw.title,
      color:     this.raw.color,
      collapsed: this.raw.collapsed,
      windowId:  this.raw.windowId,
    };
  }

  get collapsedMembersCounterItem() {
    if (this._collapsedMembersCounterItem) {
      return this._collapsedMembersCounterItem;
    }
    this._collapsedMembersCounterItem = {
      id:        this.raw.id,
      windowId:  this.raw.windowId,
      color:     this.raw.color,
      type:      TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER,
      group:     this.raw,
    };
    new TabGroupCollapsedMembersCounter(this._collapsedMembersCounterItem);
    return this._collapsedMembersCounterItem;
  }


  //===================================================================
  // class methods
  //===================================================================

  static get(groupId) {
    return TabsStore.tabGroups.get(groupId);
  }

  static init(group) {
    if (group.$TST instanceof TabGroup) {
      return group;
    }
    if ('index' in group) {
      group.index = -1;
    }
    if ('incognito' in group) {
      group.incognito = false;
    }
    group.$TST = new TabGroup(group);
    return group;
  }

  static getMembers(groupId, options = {}) {
    const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId();
    return TabsStore.queryAll({
      windowId,
      tabs:   TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId),
      living: true,
      groupId,
      ordered: true,
      ...options
    });
  }

  static getFirstMember(groupId, options = {}) {
    const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId();
    return TabsStore.query({
      windowId,
      tabs:   TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId),
      living: true,
      groupId,
      ...options,
      ordered: true,
      first: true,
    });
  }

  static getLastMember(groupId, options = {}) {
    const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId();
    return TabsStore.query({
      windowId,
      tabs:   TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId),
      living: true,
      groupId,
      ...options,
      ordered: true,
      last: true,
    });
  }

  // https://searchfox.org/mozilla-central/rev/578d9c83f046d8c361ac6b98b297c27990d468fd/browser/components/tabbrowser/content/tabgroup-menu.js#25
  static COLORS = [
    'blue',
    'purple',
    'cyan',
    'orange',
    'yellow',
    'pink',
    'green',
    'gray',
    'red',
  ];

  static getNextUnusedColor(windowId = null) {
    if (!windowId) {
      windowId = TabsStore.getCurrentWindowId();
    }
    const unusedColors = new Set(TabGroup.COLORS);
    for (const group of TabsStore.windows.get(windowId).tabGroups.values()) {
      unusedColors.delete(group.color);
    }
    if (unusedColors.size > 0) {
      return [...unusedColors][0];
    }
    // all colors are used
    const index = Math.floor(Math.random() * TabGroup.COLORS.length);
    return TabGroup.COLORS[index];
  }
}


export class Tab extends TreeItem {
  //===================================================================
  // tab tracking events
  //===================================================================

  static onTracked      = new EventListenerManager();
  static onDestroyed    = new EventListenerManager();
  static onInitialized  = new EventListenerManager();

  //===================================================================
  // general tab events
  //===================================================================

  static onGroupTabDetected = new EventListenerManager();
  static onLabelUpdated     = new EventListenerManager();
  static onStateChanged     = new EventListenerManager();
  static onPinned           = new EventListenerManager();
  static onUnpinned         = new EventListenerManager();
  static onHidden           = new EventListenerManager();
  static onShown            = new EventListenerManager();
  static onTabInternallyMoved     = new EventListenerManager();
  static onCollapsedStateChanged  = new EventListenerManager();
  static onMutedStateChanged      = new EventListenerManager();
  static onAutoplayBlockedStateChanged = new EventListenerManager();
  static onSharingStateChanged    = new EventListenerManager();

  static onBeforeCreate     = new EventListenerManager();
  static onCreating         = new EventListenerManager();
  static onCreated          = new EventListenerManager();
  static onRemoving         = new EventListenerManager();
  static onRemoved          = new EventListenerManager();
  static onMoving           = new EventListenerManager();
  static onMoved            = new EventListenerManager();
  static onActivating       = new EventListenerManager();
  static onActivated        = new EventListenerManager();
  static onUnactivated      = new EventListenerManager();
  static onUpdated          = new EventListenerManager();
  static onRestored         = new EventListenerManager();
  static onWindowRestoring  = new EventListenerManager();
  static onAttached         = new EventListenerManager();
  static onDetached         = new EventListenerManager();

  static onMultipleTabsRemoving = new EventListenerManager();
  static onMultipleTabsRemoved  = new EventListenerManager();
  static onChangeMultipleTabsRestorability = new EventListenerManager();
  static onStateChanged  = new EventListenerManager();
  static onNativeGroupModified = new EventListenerManager();


  constructor(raw) {
    const alreadyTracked = Tab.get(raw.id);
    if (alreadyTracked)
      return alreadyTracked.$TST;

    log(`tab ${dumpTab(raw)} is newly tracked: `, raw);

    super(raw);

    this.promisedUniqueId = new Promise((resolve, _reject) => {
      this.onUniqueIdGenerated = resolve;
    });

    this.index = raw.index;

    this.updatingOpenerTabIds = []; // this must be an array, because same opener tab id can appear multiple times.

    this.newRelatedTabsCount = 0;

    this.lastSoundStateCounts = {
      soundPlaying: 0,
      muted:        0,
      autoPlayBlocked: 0,
    };
    this.soundPlayingChildrenIds = new Set();
    this.maybeSoundPlayingChildrenIds = new Set();
    this.mutedChildrenIds = new Set();
    this.maybeMutedChildrenIds = new Set();
    this.autoplayBlockedChildrenIds = new Set();
    this.maybeAutoplayBlockedChildrenIds = new Set();

    this.lastSharingStateCounts = {
      camera:     0,
      microphone: 0,
      screen:     0,
    };
    this.sharingCameraChildrenIds = new Set();
    this.maybeSharingCameraChildrenIds = new Set();
    this.sharingMicrophoneChildrenIds = new Set();
    this.maybeSharingMicrophoneChildrenIds = new Set();
    this.sharingScreenChildrenIds = new Set();
    this.maybeSharingScreenChildrenIds = new Set();

    this.opened = new Promise((resolve, reject) => {
      const resolvers = mOpenedResolvers.get(raw.id) || new Set();
      resolvers.add({ resolve, reject });
      mOpenedResolvers.set(raw.id, resolvers);
    });

    TabsStore.tabs.set(raw.id, raw);

    const win = TabsStore.windows.get(raw.windowId) || new Window(raw.windowId);
    win.trackTab(raw);

    // Don't update indexes here, instead Window.prototype.trackTab()
    // updates indexes because indexes are bound to windows.
    // TabsStore.updateIndexesForTab(raw);

    if (raw.active) {
      TabsStore.activeTabInWindow.set(raw.windowId, raw);
      TabsStore.activeTabsInWindow.get(raw.windowId).add(raw);
    }
    else {
      TabsStore.activeTabsInWindow.get(raw.windowId).delete(raw);
    }
    setTimeout(() => {
      if (!TabsStore.ensureLivingItem(raw)) {
        return;
      }
      if (raw.active)  {
        Tab.onActivated.dispatch(raw);
      }
      else {
        Tab.onUnactivated.dispatch(raw);
      }
    }, 0);

    const incompletelyTrackedTabsPerWindow = mIncompletelyTrackedTabs.get(raw.windowId) || new Set();
    incompletelyTrackedTabsPerWindow.add(raw);
    mIncompletelyTrackedTabs.set(raw.windowId, incompletelyTrackedTabsPerWindow);
    this.promisedUniqueId.then(() => {
      incompletelyTrackedTabsPerWindow.delete(raw);
      Tab.onTracked.dispatch(raw);
    });

    // We should initialize private properties with blank value for better performance with a fixed shape.
    this.delayedInheritSoundStateFromChildren = null;
  }

  destroy() {
    mPromisedTrackedTabs.delete(`${this.id}:true`);
    mPromisedTrackedTabs.delete(`${this.id}:false`);

    Tab.onDestroyed.dispatch(this.raw);
    this.detach();

    if (this.temporaryMetadata.has('reservedCleanupNeedlessGroupTab')) {
      clearTimeout(this.temporaryMetadata.get('reservedCleanupNeedlessGroupTab'));
      this.temporaryMetadata.delete('reservedCleanupNeedlessGroupTab');
    }

    TabsStore.tabs.delete(this.id);
    if (this.uniqueId)
      TabsStore.tabsByUniqueId.delete(this.uniqueId.id);

    TabsStore.removeTabFromIndexes(this.raw);

    super.destroy();
  }

  clear() {
    super.clear();

    this.parentId = null;
    this.childIds = [];
    this.cachedAncestorIds   = null;
    this.cachedDescendantIds = null;
  }

  startMoving() {
    let onTabMoved;
    const promisedMoved = new Promise((resolve, _reject) => {
      onTabMoved = resolve;
    });
    const movingTabs = mMovingTabs.get(this.raw.windowId) || new Set();
    movingTabs.add(promisedMoved);
    mMovingTabs.set(this.raw.windowId, movingTabs);
    promisedMoved.then(() => {
      movingTabs.delete(promisedMoved);
    });
    return onTabMoved;
  }

  updateUniqueId(options = {}) {
    if (!this.raw) {
      const error = new Error('FATAL ERROR: updateUniqueId() is unavailable for an invalid tab');
      console.log(error);
      throw error;
    }
    if (options.id) {
      if (this.uniqueId.id)
        TabsStore.tabsByUniqueId.delete(this.uniqueId.id);
      this.uniqueId.id = options.id;
      TabsStore.tabsByUniqueId.set(options.id, this.raw);
      this.setAttribute(Constants.kPERSISTENT_ID, options.id);
      return Promise.resolve(this.uniqueId);
    }
    return UniqueId.request(this.raw, options).then(uniqueId => {
      if (uniqueId && TabsStore.ensureLivingItem(this.raw)) { // possibly removed from document while waiting
        this.uniqueId = uniqueId;
        TabsStore.tabsByUniqueId.set(uniqueId.id, this.raw);
        this.setAttribute(Constants.kPERSISTENT_ID, uniqueId.id);
      }
      return uniqueId || {};
    }).catch(error => {
      console.log(`FATAL ERROR: Failed to get unique id for a tab ${this.id}: `, error);
      return {};
    });
  }

  get type() {
    return TreeItem.TYPE_TAB;
  }

  get tab() {
    return this.raw;
  }

  get nativeTabGroup() {
    if (this.raw.groupId == -1) {
      return null;
    }
    return TabGroup.get(this.raw.groupId);
  }

  //===================================================================
  // status of tab
  //===================================================================

  get soundPlaying() {
    return !!(this.raw?.audible && !this.raw?.mutedInfo.muted);
  }
  get maybeSoundPlaying() {
    return (this.soundPlaying ||
            (this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER) &&
             this.hasChild));
  }

  get muted() {
    return !!(this.raw?.mutedInfo?.muted);
  }
  get maybeMuted() {
    return (this.muted ||
            (this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER) &&
             this.hasChild));
  }

  get autoplayBlocked() {
    return this.states.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED);
  }
  get maybeAutoplayBlocked() {
    return (this.autoplayBlocked ||
            (this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER) &&
             this.hasChild));
  }

  get sharingCamera() {
    return !!(this.raw?.sharingState?.camera);
  }
  get maybeSharingCamera() {
    return (this.sharingCamera ||
            (this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER) &&
             this.hasChild));
  }

  get sharingMicrophone() {
    return !!(this.raw?.sharingState?.microphone);
  }
  get maybeSharingMicrophone() {
    return (this.sharingMicrophone ||
            (this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER) &&
             this.hasChild));
  }

  get sharingScreen() {
    return !!(this.raw?.sharingState?.screen);
  }
  get maybeSharingScreen() {
    return (this.sharingScreen ||
            (this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER) &&
             this.hasChild));
  }

  get precedesPinnedTab() {
    const following = this.nearestVisibleFollowingTab;
    return following?.pinned;
  }

  get followsUnpinnedTab() {
    const preceding = this.nearestVisiblePrecedingTab;
    return preceding && !preceding.pinned;
  }

  get isNewTabCommandTab() {
    if (!this.raw ||
        !configs.guessNewOrphanTabAsOpenedByNewTabCommand)
      return false;

    if (this.raw.$isNewTabCommandTab)
      return true;

    // Firefox sets "New Tab" title to a new tab command tab, even if
    // "Blank Page" is chosen as the new tab page. So we can detect the case
    // safely here.
    // (confirmed on Firefox 124)
    if (isNewTabCommandTab(this.raw))
      return true;

    // Firefox always opens a blank tab as the placeholder, when trying to
    // open a bookmark in a new tab. So, we cannot determine the tab is
    // "really opened as a new blank tab" or "just as a placeholder for an
    // Open in New Tab operation", when the user choose the "Blank Page"
    // as the new tab page and the new tab page is opened without the title
    // "New Tab" due to any reason.
    // But, when "Blank Page" is chosen as the new tab page, Firefox loads
    // "about:blank" into a newly opened blank tab. As the result both current
    // URL and the previous URL become "about:blank". This is an important
    // difference between "a new blank tab" and "a blank tab opened for an
    // Open in New Tab command".
    // (confirmed on Firefox 124)
    if (this.raw.url == 'about:blank' &&
        this.raw.previousUrl != 'about:blank')
      return false;

    return false;
  }

  get isGroupTab() {
    return this.states.has(Constants.kTAB_STATE_GROUP_TAB) ||
           this.hasGroupTabURL;
  }

  get hasGroupTabURL() {
    return !!(this.raw?.url?.indexOf(Constants.kGROUP_TAB_URI) == 0);
  }

  get isTemporaryGroupTab() {
    if (!this.raw || !this.isGroupTab)
      return false;
    return (new URL(this.raw.url)).searchParams.get('temporary') == 'true';
  }

  get isTemporaryAggressiveGroupTab() {
    if (!this.raw || !this.isGroupTab)
      return false;
    return (new URL(this.raw.url)).searchParams.get('temporaryAggressive') == 'true';
  }

  get replacedParentGroupTabCount() {
    if (!this.raw || !this.isGroupTab)
      return 0;
    const count = parseInt((new URL(this.raw.url)).searchParams.get('replacedParentCount'));
    return isNaN(count) ? 0 : count;
  }

  // Firefox Multi-Account Containers
  // https://addons.mozilla.org/firefox/addon/multi-account-containers/
  // Temporary Containers
  // https://addons.mozilla.org/firefox/addon/temporary-containers/
  get mayBeReplacedWithContainer() {
    return !!(
      this.$possiblePredecessorPreviousTab ||
      this.$possiblePredecessorNextTab
    );
  }
  get $possiblePredecessorPreviousTab() {
    const prevTab = this.unsafePreviousTab;
    return (
      prevTab &&
      this.raw &&
      this.raw.cookieStoreId != prevTab.cookieStoreId &&
      this.raw.url == prevTab.url
    ) ? prevTab : null;
  }
  get $possiblePredecessorNextTab() {
    const nextTab = this.unsafeNextTab;
    return (
      nextTab &&
      this.raw &&
      this.raw.cookieStoreId != nextTab.cookieStoreId &&
      this.raw.url == nextTab.url
    ) ? nextTab : null;
  }
  get possibleSuccessorWithDifferentContainer() {
    const firstChild = this.firstChild;
    const nextTab = this.nextTab;
    const prevTab = this.previousTab;
    return (
      (firstChild &&
       firstChild.$TST.$possiblePredecessorPreviousTab == this.raw &&
       firstChild) ||
      (nextTab &&
       !nextTab.$TST.temporaryMetadata.has('openedCompletely') &&
       nextTab.$TST.$possiblePredecessorPreviousTab == this.raw &&
       nextTab) ||
      (prevTab &&
       !prevTab.$TST.temporaryMetadata.has('openedCompletely') &&
       prevTab.$TST.$possiblePredecessorNextTab == this.raw &&
       prevTab)
    );
  }

  get selected() {
    return this.states.has(Constants.kTAB_STATE_SELECTED) ||
             (this.hasOtherHighlighted && !!(this.raw?.highlighted));
  }

  get multiselected() {
    return this.raw &&
             this.selected &&
             (this.hasOtherHighlighted ||
              TabsStore.selectedTabsInWindow.get(this.raw.windowId).size > 1);
  }

  get hasOtherHighlighted() {
    const highlightedTabs = this.raw && TabsStore.highlightedTabsInWindow.get(this.raw.windowId);
    return !!(highlightedTabs && highlightedTabs.size > 1);
  }

  get canBecomeSticky() {
    if (this.raw?.pinned) {
      return false;
    }
    return super.canBecomeSticky;
  }

  get promisedPossibleOpenerBookmarks() {
    if ('possibleOpenerBookmarks' in this)
      return Promise.resolve(this.possibleOpenerBookmarks);
    return new Promise(async (resolve, _reject) => {
      if (!browser.bookmarks || !this.raw)
        return resolve(this.possibleOpenerBookmarks = []);
      // A new tab from bookmark is opened with a title: its URL without the scheme part.
      const url = this.raw.$possibleInitialUrl;
      try {
        const possibleBookmarks = await Promise.all([
          this._safeSearchBookmstksWithUrl(`http://${url}`),
          this._safeSearchBookmstksWithUrl(`http://www.${url}`),
          this._safeSearchBookmstksWithUrl(`https://${url}`),
          this._safeSearchBookmstksWithUrl(`https://www.${url}`),
          this._safeSearchBookmstksWithUrl(`ftp://${url}`),
          this._safeSearchBookmstksWithUrl(`moz-extension://${url}`),
          this._safeSearchBookmstksWithUrl(url), // about:* and so on
        ]);
        log(`promisedPossibleOpenerBookmarks for tab ${this.id} (${url}): `, possibleBookmarks);
        resolve(this.possibleOpenerBookmarks = possibleBookmarks.flat());
      }
      catch(error) {
        log(`promisedPossibleOpenerBookmarks for the tab {this.id} (${url}): `, error);
        // If it is detected as "not a valid URL", then
        // it cannot be a tab opened from a bookmark.
        resolve(this.possibleOpenerBookmarks = []);
      }
    });
  }
  async _safeSearchBookmstksWithUrl(url) {
    try {
      return await browser.bookmarks.search({ url });
    }
    catch(error) {
      log(`_searchBookmstksWithUrl failed: tab ${this.id} (${url}): `, error);
      try {
        // bookmarks.search() does not accept "moz-extension:" URL
        // via a queyr with "url" on Firefox 105 and later - it raises an error as
        // "Uncaught Error: Type error for parameter query (Value must either:
        // be a string value, or .url must match the format "url") for bookmarks.search."
        // Thus we use a query with "query" to avoid the error.
        // See also: https://github.com/piroor/treestyletab/issues/3203
        //           https://bugzilla.mozilla.org/show_bug.cgi?id=1791313
        const bookmarks = await browser.bookmarks.search({ query: url }).catch(_error => []);
        return bookmarks.filter(bookmark => bookmark.url == url);
      }
      catch(_error) {
        return [];
      }
    }
  }

  get cookieStoreName() {
    const identity = this.raw?.cookieStoreId && ContextualIdentities.get(this.raw.cookieStoreId);
    return identity ? identity.name : null;
  }

  get defaultTooltipText() {
    return this.cookieStoreName ? `${this.raw.title} - ${this.cookieStoreName}` : super.defaultTooltipText;
  }

  get tooltipHtml() {
    return this.cookieStoreName ?
      `<span class="title-line"
            ><span class="title"
                  >${sanitizeForHTMLText(this.raw.title)}</span
            ><span class="cookieStoreName"
                  >${sanitizeForHTMLText(this.cookieStoreName)}</span></span>` :
      super.tooltipHtml;
  }

  registerTooltipText(ownerId, text, isHighPriority = false) {
    super.registerTooltipText(ownerId, text, isHighPriority);
    if (Constants.IS_BACKGROUND)
      Tab.broadcastTooltipText(this.raw);
  }

  unregisterTooltipText(ownerId) {
    super.unregisterTooltipText(ownerId);
    if (Constants.IS_BACKGROUND)
      Tab.broadcastTooltipText(this.raw);
  }

  get collapsedByParent() {
    return this._shouldBeCollapsedByParent();
  }
  get promisedCollapsedByParent() {
    if (this.raw.groupId == -1) {
      return this.collapsedByParent;
    }
    return browser.tabGroups.get(this.raw.groupId).then(group => {
      return this._shouldBeCollapsedByParent(group)
    });
  }
  _shouldBeCollapsedByParent(group) {
    if (this.raw.groupId == -1) {
      return !!this.topmostSubtreeCollapsedAncestor;
    }
    if (this.raw.active) {
      // simulate "visible active tab in collapsed tab group" behavior of Firefox itself
      return false;
    }
    if (this.topmostSubtreeCollapsedAncestor) {
      return true;
    }
    return (group || this.nativeTabGroup)?.collapsed;
  }

  //===================================================================
  // neighbor tabs
  //===================================================================

  get nextTab() {
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.controllableTabsInWindow.get(this.raw.windowId),
      fromId:   this.id,
      controllable: true,
      index:    (index => index > this.raw.index)
    });
  }

  get previousTab() {
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.controllableTabsInWindow.get(this.raw.windowId),
      fromId:   this.id,
      controllable: true,
      index:    (index => index < this.raw.index),
      last:     true
    });
  }

  get unsafeNextTab() {
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      fromId:   this.id,
      index:    (index => index > this.raw.index)
    });
  }

  get unsafePreviousTab() {
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      fromId:   this.id,
      index:    (index => index < this.raw.index),
      last:     true
    });
  }

  get nearestCompletelyOpenedNormalFollowingTab() { // including hidden tabs!
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.unpinnedTabsInWindow.get(this.raw.windowId),
      states:   [Constants.kTAB_STATE_CREATING, false],
      fromId:   this.id,
      living:   true,
      index:    (index => index > this.raw.index)
    });
  }

  get nearestCompletelyOpenedNormalPrecedingTab() { // including hidden tabs!
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.unpinnedTabsInWindow.get(this.raw.windowId),
      states:   [Constants.kTAB_STATE_CREATING, false],
      fromId:   this.id,
      living:   true,
      index:    (index => index < this.raw.index),
      last:     true
    });
  }

  get nearestVisibleFollowingTab() { // visible, not-collapsed
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.visibleTabsInWindow.get(this.raw.windowId),
      fromId:   this.id,
      visible:  true,
      index:    (index => index > this.raw.index)
    });
  }

  get unsafeNearestExpandedFollowingTab() { // not-collapsed, possibly hidden
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.expandedTabsInWindow.get(this.raw.windowId),
      fromId:   this.id,
      index:    (index => index > this.raw.index)
    });
  }

  get nearestVisiblePrecedingTab() { // visible, not-collapsed
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.visibleTabsInWindow.get(this.raw.windowId),
      fromId:   this.id,
      visible:  true,
      index:    (index => index < this.raw.index),
      last:     true
    });
  }

  get unsafeNearestExpandedPrecedingTab() { // not-collapsed, possibly hidden
    return this.raw && TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.expandedTabsInWindow.get(this.raw.windowId),
      fromId:   this.id,
      index:    (index => index < this.raw.index),
      last:     true
    });
  }

  get nearestLoadedTab() {
    const tabs = this.raw && TabsStore.visibleTabsInWindow.get(this.raw.windowId);
    return this.raw && (
      // nearest following tab
      TabsStore.query({
        windowId:  this.raw.windowId,
        tabs,
        discarded: false,
        fromId:    this.id,
        visible:   true,
        index:     (index => index > this.raw.index)
      }) ||
      // nearest preceding tab
      TabsStore.query({
        windowId:  this.raw.windowId,
        tabs,
        discarded: false,
        fromId:    this.id,
        visible:   true,
        index:     (index => index < this.raw.index),
        last:      true
      })
    );
  }

  get nearestLoadedTabInTree() {
    if (!this.raw)
      return null;
    let tab = this.raw;
    const tabs = TabsStore.visibleTabsInWindow.get(tab.windowId);
    let lastLastDescendant;
    while (tab) {
      const parent = tab.$TST.parent;
      if (!parent)
        return null;
      const lastDescendant = parent.$TST.lastDescendant;
      const loadedTab = (
        // nearest following tab
        TabsStore.query({
          windowId:     tab.windowId,
          tabs,
          descendantOf: parent.id,
          discarded:    false,
          '!id':        this.id,
          fromId:       (lastLastDescendant || this.raw).id,
          toId:         lastDescendant.id,
          visible:      true,
          index:        (index => index > this.raw.index)
        }) ||
        // nearest preceding tab
        TabsStore.query({
          windowId:     tab.windowId,
          tabs,
          descendantOf: parent.id,
          discarded:    false,
          '!id':        this.id,
          fromId:       tab.id,
          toId:         parent.$TST.firstChild.id,
          visible:      true,
          index:        (index => index < tab.index),
          last:         true
        })
      );
      if (loadedTab)
        return loadedTab;
      if (!parent.discarded)
        return parent;
      lastLastDescendant = lastDescendant;
      tab = tab.$TST.parent;
    }
    return null;
  }

  get nearestLoadedSiblingTab() {
    const parent = this.parent;
    if (!parent || !this.raw)
      return null;
    const tabs = TabsStore.visibleTabsInWindow.get(this.raw.windowId);
    return (
      // nearest following tab
      TabsStore.query({
        windowId:  this.raw.windowId,
        tabs,
        childOf:   parent.id,
        discarded: false,
        fromId:    this.id,
        toId:      parent.$TST.lastChild.id,
        visible:   true,
        index:     (index => index > this.raw.index)
      }) ||
      // nearest preceding tab
      TabsStore.query({
        windowId:  this.raw.windowId,
        tabs,
        childOf:   parent.id,
        discarded: false,
        fromId:    this.id,
        toId:      parent.$TST.firstChild.id,
        visible:   true,
        index:     (index => index < this.raw.index),
        last:      true
      })
    );
  }

  get nearestSameTypeRenderedTab() {
    let tab = this.raw;
    const pinned = tab.pinned;
    while (tab.$TST.unsafeNextTab) {
      tab = tab.$TST.unsafeNextTab;
      if (tab.pinned != pinned)
        return null;
      if (tab.$TST.element &&
          tab.$TST.element.parentNode)
        return tab;
    }
    return null;
  }

  //===================================================================
  // tree relations
  //===================================================================

  set parent(tab) {
    const newParentId = tab && (typeof tab == 'number' ? tab : tab.id);
    if (!this.raw ||
        newParentId == this.parentId)
      return tab;

    const oldParent = this.parent;
    this.parentId = newParentId;
    this.invalidateCachedAncestors();
    const parent = this.parent;
    if (parent) {
      this.setAttribute(Constants.kPARENT, parent.id);
      parent.$TST.invalidateCachedDescendants();

      if (this.states.has(Constants.kTAB_STATE_SOUND_PLAYING))
        parent.$TST.soundPlayingChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER))
        parent.$TST.maybeSoundPlayingChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_MUTED))
        parent.$TST.mutedChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER))
        parent.$TST.maybeMutedChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED))
        parent.$TST.autoplayBlockedChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER))
        parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id);
      parent.$TST.inheritSoundStateFromChildren();

      if (this.states.has(Constants.kTAB_STATE_SHARING_CAMERA))
        parent.$TST.sharingCameraChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER))
        parent.$TST.maybeSharingCameraChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_SHARING_MICROPHONE))
        parent.$TST.sharingMicrophoneChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER))
        parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_SHARING_SCREEN))
        parent.$TST.sharingScreenChildrenIds.add(this.id);
      if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER))
        parent.$TST.maybeSharingScreenChildrenIds.add(this.id);
      parent.$TST.inheritSharingStateFromChildren();

      TabsStore.removeRootTab(this.raw);
    }
    else {
      this.removeAttribute(Constants.kPARENT);
      TabsStore.addRootTab(this.raw);
    }
    if (oldParent && oldParent.id != this.parentId) {
      oldParent.$TST.soundPlayingChildrenIds.delete(this.id);
      oldParent.$TST.maybeSoundPlayingChildrenIds.delete(this.id);
      oldParent.$TST.mutedChildrenIds.delete(this.id);
      oldParent.$TST.maybeMutedChildrenIds.delete(this.id);
      oldParent.$TST.autoplayBlockedChildrenIds.delete(this.id);
      oldParent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id);
      oldParent.$TST.inheritSoundStateFromChildren();

      oldParent.$TST.sharingCameraChildrenIds.delete(this.id);
      oldParent.$TST.maybeSharingCameraChildrenIds.delete(this.id);
      oldParent.$TST.sharingMicrophoneChildrenIds.delete(this.id);
      oldParent.$TST.maybeSharingScreenChildrenIds.delete(this.id);
      oldParent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id);
      oldParent.$TST.maybeSharingScreenChildrenIds.delete(this.id);
      oldParent.$TST.inheritSharingStateFromChildren();

      oldParent.$TST.children = oldParent.$TST.childIds.filter(id => id != this.id);
    }
    return tab;
  }
  get parent() {
    return this.raw && this.parentId && TabsStore.ensureLivingItem(Tab.get(this.parentId));
  }

  get hasParent() {
    return !!this.parentId;
  }

  get ancestorIds() {
    if (!this.cachedAncestorIds)
      this.updateAncestors();
    return this.cachedAncestorIds;
  }

  get ancestors() {
    return mapAndFilter(this.ancestorIds,
                        id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined);
  }

  updateAncestors() {
    const ancestors = [];
    this.cachedAncestorIds = [];
    if (!this.raw)
      return ancestors;
    let descendant = this.raw;
    while (true) {
      const parent = Tab.get(descendant.$TST.parentId);
      if (!parent)
        break;
      ancestors.push(parent);
      this.cachedAncestorIds.push(parent.id);
      descendant = parent;
    }
    return ancestors;
  }

  get level() {
    return this.ancestorIds.length;
  }

  invalidateCachedAncestors() {
    this.cachedAncestorIds = null;
    for (const child of this.children) {
      child.$TST.invalidateCachedAncestors();
    }
    this.invalidateCache();
  }

  get rootTab() {
    const ancestors = this.ancestors;
    return ancestors.length > 0 ? ancestors[ancestors.length-1] : this.raw ;
  }

  get topmostSubtreeCollapsedAncestor() {
    for (const ancestor of [...this.ancestors].reverse()) {
      if (ancestor.$TST.subtreeCollapsed)
        return ancestor;
    }
    return null;
  }

  get nearestVisibleAncestorOrSelf() {
    for (const ancestor of this.ancestors) {
      if (!ancestor.$TST.collapsed)
        return ancestor;
    }
    if (!this.collapsed)
      return this.raw;
    return null;
  }

  get nearestFollowingRootTab() {
    return TabsStore.query({
      windowId:  this.raw.windowId,
      tabs:      TabsStore.rootTabsInWindow.get(this.raw.windowId),
      fromId:    this.id,
      living:    true,
      index:     (index => index > this.raw.index),
      hasParent: false,
      first:     true
    });
  }

  set children(tabs) {
    if (!this.raw)
      return tabs;

    const ancestorIds = this.ancestorIds;
    const newChildIds = mapAndFilter(tabs, tab => {
      const id = typeof tab == 'number' ? tab : tab?.id;
      if (!ancestorIds.includes(id))
        return TabsStore.ensureLivingItem(Tab.get(id)) ? id : undefined;
      console.log('FATAL ERROR: Cyclic tree structure has detected and prevented. ', {
        ancestorsOfSelf: this.ancestors,
        tabs,
        tab,
        stack: new Error().stack
      });
      return undefined;
    });
    if (newChildIds.join('|') == this.childIds.join('|'))
      return tabs;

    const oldChildren = this.children;
    this.childIds = newChildIds;
    this.sortAndInvalidateChildren();
    if (this.childIds.length > 0) {
      this.setAttribute(Constants.kCHILDREN, `|${this.childIds.join('|')}|`);
      if (this.isSubtreeCollapsable)
        TabsStore.addSubtreeCollapsableTab(this.raw);
    }
    else {
      this.removeAttribute(Constants.kCHILDREN);
      TabsStore.removeSubtreeCollapsableTab(this.raw);
    }
    for (const child of Array.from(new Set(this.children.concat(oldChildren)))) {
      if (this.childIds.includes(child.id))
        child.$TST.parent = this.id;
      else
        child.$TST.parent = null;
    }
    return tabs;
  }
  get children() {
    return mapAndFilter(this.childIds,
                        id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined);
  }

  sortAndInvalidateChildren() {
    // Tab.get(tabId) calls into TabsStore.tabs.get(tabId), which is just a
    // Map. This is acceptable to repeat in order to avoid two array copies,
    // especially on larger tab sets.
    this.childIds.sort((a, b) => TreeItem.compare(Tab.get(a), Tab.get(b)));
    this.invalidateCachedDescendants();
  }

  get hasChild() {
    return this.childIds.length > 0;
  }

  get descendants() {
    if (!this.cachedDescendantIds)
      return this.updateDescendants();
    return mapAndFilter(this.cachedDescendantIds,
                        id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined);
  }

  updateDescendants() {
    let descendants = [];
    this.cachedDescendantIds = [];
    for (const child of this.children) {
      descendants.push(child);
      descendants = descendants.concat(child.$TST.descendants);
      this.cachedDescendantIds.push(child.id);
      this.cachedDescendantIds = this.cachedDescendantIds.concat(child.$TST.cachedDescendantIds);
    }
    return descendants;
  }

  invalidateCachedDescendants() {
    this.cachedDescendantIds = null;
    const parent = this.parent;
    if (parent)
      parent.$TST.invalidateCachedDescendants();
    this.invalidateCache();
  }

  get nextSiblingTab() {
    if (!this.raw)
      return null;
    const parent = this.parent;
    if (parent) {
      const siblingIds = parent.$TST.childIds;
      const index = siblingIds.indexOf(this.id);
      const siblingId = index < siblingIds.length - 1 ? siblingIds[index + 1] : null ;
      if (!siblingId)
        return null;
      return Tab.get(siblingId);
    }
    else {
      const nextSibling = TabsStore.query({
        windowId:  this.raw.windowId,
        tabs:      TabsStore.rootTabsInWindow.get(this.raw.windowId),
        fromId:    this.id,
        living:    true,
        index:     (index => index > this.raw.index),
        hasParent: false,
        first:     true
      });
      // We should treat only pinned tab as the next sibling tab of a pinned
      // tab. For example, if the last pinned tab is closed, Firefox moves
      // focus to the first normal tab. But the previous pinned tab looks
      // natural on TST because pinned tabs are visually grouped.
      if (nextSibling &&
          nextSibling.pinned != this.raw.pinned)
        return null;
      return nextSibling;
    }
  }

  get previousSiblingTab() {
    if (!this.raw)
      return null;
    const parent = this.parent;
    if (parent) {
      const siblingIds = parent.$TST.childIds;
      const index = siblingIds.indexOf(this.id);
      const siblingId = index > 0 ? siblingIds[index - 1] : null ;
      if (!siblingId)
        return null;
      return Tab.get(siblingId);
    }
    else {
      return TabsStore.query({
        windowId:  this.raw.windowId,
        tabs:      TabsStore.rootTabsInWindow.get(this.raw.windowId),
        fromId:    this.id,
        living:    true,
        index:     (index => index < this.raw.index),
        hasParent: false,
        last:      true
      });
    }
  }

  get needToBeGroupedSiblings() {
    if (!this.raw)
      return [];
    const openerTabUniqueId = this.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID);
    if (!openerTabUniqueId)
      return [];
    return TabsStore.queryAll({
      windowId:   this.raw.windowId,
      tabs:       TabsStore.toBeGroupedTabsInWindow.get(this.raw.windowId),
      normal:     true,
      '!id':      this.id,
      attributes: [
        Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID, openerTabUniqueId,
        Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, ''
      ],
      ordered:    true
    });
  }

  get precedingCanBecomeStickyTabs() {
    return TabsStore.queryAll({
      windowId:   this.raw.windowId,
      tabs:       TabsStore.canBecomeStickyTabsInWindow.get(this.raw.windowId),
      normal:     true,
      '!id':      this.id,
      ordered:    true,
      fromId:     this.id,
      reversed:   true,
    });
  }

  get followingCanBecomeStickyTabs() {
    return TabsStore.queryAll({
      windowId:   this.raw.windowId,
      tabs:       TabsStore.canBecomeStickyTabsInWindow.get(this.raw.windowId),
      normal:     true,
      '!id':      this.id,
      ordered:    true,
      fromId:     this.id,
    });
  }

  //===================================================================
  // other relations
  //===================================================================

  get openerTab() {
    if (this.raw?.openerTabId == this.id)
      return null;

    if (!this.raw?.openerTabId)
      return Tab.getOpenerFromGroupTab(this.raw);

    return TabsStore.query({
      windowId: this.raw.windowId,
      tabs:     TabsStore.livingTabsInWindow.get(this.raw.windowId),
      id:       this.raw.openerTabId,
      living:   true
    });
  }

  get hasPinnedOpener() {
    return this.openerTab?.pinned;
  }

  get hasFirefoxViewOpener() {
    return isFirefoxViewTab(this.openerTab);
  }

  get bundledTab() {
    if (!this.raw)
      return null;
    const substance = Tab.getSubstanceFromAliasGroupTab(this.raw);
    if (substance)
      return substance;
    if (this.raw.pinned)
      return Tab.getGroupTabForOpener(this.raw);
    if (this.isGroupTab)
      return Tab.getOpenerFromGroupTab(this.raw);
    return null;
  }

  get bundledTabId() {
    const tab = this.bundledTab;
    return tab ? tab.id : -1;
  }

  findSuccessor(options = {}) {
    if (!this.raw)
      return null;
    if (typeof options != 'object')
      options = {};
    const ignoredTabs = (options.ignoredTabs || []).slice(0);
    let foundTab = this.raw;
    do {
      ignoredTabs.push(foundTab);
      foundTab = foundTab.$TST.nextTab;
    } while (foundTab && ignoredTabs.includes(foundTab));
    if (!foundTab) {
      foundTab = this.raw;
      do {
        ignoredTabs.push(foundTab);
        foundTab = foundTab.$TST.nearestVisiblePrecedingTab;
      } while (foundTab && ignoredTabs.includes(foundTab));
    }
    return foundTab;
  }

  get lastRelatedTab() {
    return Tab.get(this.lastRelatedTabId) || null;
  }
  set lastRelatedTab(relatedTab) {
    if (!this.raw)
      return relatedTab;
    const previousLastRelatedTabId = this.lastRelatedTabId;
    const win = TabsStore.windows.get(this.raw.windowId);
    if (relatedTab) {
      win.lastRelatedTabs.set(this.id, relatedTab.id);
      this.newRelatedTabsCount++;
      successorTabLog(`set lastRelatedTab for ${this.id}: ${previousLastRelatedTabId} => ${relatedTab.id} (${this.newRelatedTabsCount})`);
    }
    else {
      win.lastRelatedTabs.delete(this.id);
      this.newRelatedTabsCount = 0;
      successorTabLog(`clear lastRelatedTab for ${this.id} (${previousLastRelatedTabId})`);
    }
    win.previousLastRelatedTabs.set(this.id, previousLastRelatedTabId);
    return relatedTab;
  }

  get lastRelatedTabId() {
    if (!this.raw)
      return 0;
    const win = TabsStore.windows.get(this.raw.windowId);
    return win.lastRelatedTabs.get(this.id) || 0;
  }

  get previousLastRelatedTab() {
    if (!this.raw)
      return null;
    const win = TabsStore.windows.get(this.raw.windowId);
    return Tab.get(win.previousLastRelatedTabs.get(this.id)) || null;
  }

  detach() {
    this.parent   = null;
    this.children = [];
  }


  //===================================================================
  // State
  //===================================================================

  async addState(state, { permanently, toTab, broadcast } = {}) {
    state = state && String(state) || undefined;
    if (!this.raw || !state)
      return;

    const modified = this.states && !this.states.has(state);

    super.addState(state);

    switch (state) {
      case Constants.kTAB_STATE_HIGHLIGHTED:
        TabsStore.addHighlightedTab(this.raw);
        if (this.element)
          this.element.setAttribute('aria-selected', 'true');
        if (toTab)
          this.raw.highlighted = true;
        break;

      case Constants.kTAB_STATE_SELECTED:
        TabsStore.addSelectedTab(this.raw);
        break;

      case Constants.kTAB_STATE_COLLAPSED:
      case Constants.kTAB_STATE_SUBTREE_COLLAPSED:
        if (this.isSubtreeCollapsable)
          TabsStore.addSubtreeCollapsableTab(this.raw);
        else
          TabsStore.removeSubtreeCollapsableTab(this.raw);
        break;

      case Constants.kTAB_STATE_HIDDEN:
        TabsStore.removeVisibleTab(this.raw);
        TabsStore.removeControllableTab(this.raw);
        if (toTab)
          this.raw.hidden = true;
        break;

      case Constants.kTAB_STATE_PINNED:
        TabsStore.addPinnedTab(this.raw);
        TabsStore.removeUnpinnedTab(this.raw);
        if (toTab)
          this.raw.pinned = true;
        break;

      case Constants.kTAB_STATE_BUNDLED_ACTIVE:
        TabsStore.addBundledActiveTab(this.raw);
        break;

      case Constants.kTAB_STATE_SOUND_PLAYING: {
        const parent = this.parent;
        if (parent)
          parent.$TST.soundPlayingChildrenIds.add(this.id);
      } break;
      case Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSoundPlayingChildrenIds.add(this.id);
      } break;

      case Constants.kTAB_STATE_AUDIBLE:
        if (toTab)
          this.raw.audible = true;
        break;

      case Constants.kTAB_STATE_MUTED: {
        const parent = this.parent;
        if (parent)
          parent.$TST.mutedChildrenIds.add(this.id);
        if (toTab)
          this.raw.mutedInfo.muted = true;
      } break;
      case Constants.kTAB_STATE_HAS_MUTED_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeMutedChildrenIds.add(this.id);
      } break;

      case Constants.kTAB_STATE_AUTOPLAY_BLOCKED: {
        const parent = this.parent;
        if (parent) {
          parent.$TST.autoplayBlockedChildrenIds.add(this.id);
          parent.$TST.inheritSoundStateFromChildren();
        }
      } break;
      case Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER: {
        const parent = this.parent;
        if (parent) {
          parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id);
          parent.$TST.inheritSoundStateFromChildren();
        }
      } break;

      case Constants.kTAB_STATE_SHARING_CAMERA: {
        const parent = this.parent;
        if (parent)
          parent.$TST.sharingCameraChildrenIds.add(this.id);
        if (toTab && this.raw.sharingState)
          this.raw.sharingState.camera = true;
      } break;
      case Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSharingCameraChildrenIds.add(this.id);
      } break;

      case Constants.kTAB_STATE_SHARING_MICROPHONE: {
        const parent = this.parent;
        if (parent)
          parent.$TST.sharingMicrophoneChildrenIds.add(this.id);
        if (toTab && this.raw.sharingState)
          this.raw.sharingState.microphone = true;
      } break;
      case Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id);
      } break;

      case Constants.kTAB_STATE_SHARING_SCREEN: {
        const parent = this.parent;
        if (parent)
          parent.$TST.sharingScreenChildrenIds.add(this.id);
        if (toTab && this.raw.sharingState)
          this.raw.sharingState.screen = 'Something';
      } break;
      case Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSharingScreenChildrenIds.add(this.id);
      } break;

      case Constants.kTAB_STATE_GROUP_TAB:
        TabsStore.addGroupTab(this.raw);
        break;

      case Constants.kTAB_STATE_PRIVATE_BROWSING:
        if (toTab)
          this.raw.incognito = true;
        break;

      case Constants.kTAB_STATE_ATTENTION:
        if (toTab)
          this.raw.attention = true;
        break;

      case Constants.kTAB_STATE_DISCARDED:
        if (toTab)
          this.raw.discarded = true;
        break;

      case 'loading':
        TabsStore.addLoadingTab(this.raw);
        if (toTab)
          this.raw.status = state;
        break;

      case 'complete':
        TabsStore.removeLoadingTab(this.raw);
        if (toTab)
          this.raw.status = state;
        break;
    }

    if (TreeItem.allAutoStickyStates.has(state)) {
      if (this.canBecomeSticky)
        TabsStore.addCanBecomeStickyTab(this.raw);
      else
        TabsStore.removeCanBecomeStickyTab(this.raw);
    }

    if (this.raw &&
        modified &&
        state != Constants.kTAB_STATE_ACTIVE &&
        Constants.IS_BACKGROUND &&
        broadcast !== false)
      Tab.broadcastState(this.raw, {
        add: [state],
      });
    if (permanently) {
      const states = await this.getPermanentStates();
      if (!states.includes(state)) {
        states.push(state);
        await browser.sessions.setTabValue(this.id, Constants.kPERSISTENT_STATES, states).catch(ApiTabs.createErrorSuppressor());
      }
    }
    if (modified) {
      this.invalidateCache();
      if (this.raw)
        Tab.onStateChanged.dispatch(this.raw, state, true);
    }
  }

  async removeState(state, { permanently, toTab, broadcast } = {}) {
    state = state && String(state) || undefined;
    if (!this.raw || !state)
      return;

    const modified = this.states?.has(state);

    super.removeState(state);

    switch (state) {
      case Constants.kTAB_STATE_HIGHLIGHTED:
        TabsStore.removeHighlightedTab(this.raw);
        if (this.element)
          this.element.setAttribute('aria-selected', 'false');
        if (toTab)
          this.raw.highlighted = false;
        break;

      case Constants.kTAB_STATE_SELECTED:
        TabsStore.removeSelectedTab(this.raw);
        break;

      case Constants.kTAB_STATE_COLLAPSED:
      case Constants.kTAB_STATE_SUBTREE_COLLAPSED:
        if (this.isSubtreeCollapsable)
          TabsStore.addSubtreeCollapsableTab(this.raw);
        else
          TabsStore.removeSubtreeCollapsableTab(this.raw);
        break;

      case Constants.kTAB_STATE_HIDDEN:
        if (!this.collapsed)
          TabsStore.addVisibleTab(this.raw);
        TabsStore.addControllableTab(this.raw);
        if (toTab)
          this.raw.hidden = false;
        break;

      case Constants.kTAB_STATE_PINNED:
        TabsStore.removePinnedTab(this.raw);
        TabsStore.addUnpinnedTab(this.raw);
        if (toTab)
          this.raw.pinned = false;
        break;

      case Constants.kTAB_STATE_BUNDLED_ACTIVE:
        TabsStore.removeBundledActiveTab(this.raw);
        break;

      case Constants.kTAB_STATE_SOUND_PLAYING: {
        const parent = this.parent;
        if (parent)
          parent.$TST.soundPlayingChildrenIds.delete(this.id);
      } break;
      case Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSoundPlayingChildrenIds.delete(this.id);
      } break;

      case Constants.kTAB_STATE_AUDIBLE:
        if (toTab)
          this.raw.audible = false;
        break;

      case Constants.kTAB_STATE_MUTED: {
        const parent = this.parent;
        if (parent)
          parent.$TST.mutedChildrenIds.delete(this.id);
        if (toTab)
          this.raw.mutedInfo.muted = false;
      } break;
      case Constants.kTAB_STATE_HAS_MUTED_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeMutedChildrenIds.delete(this.id);
      } break;

      case Constants.kTAB_STATE_AUTOPLAY_BLOCKED: {
        const parent = this.parent;
        if (parent) {
          parent.$TST.autoplayBlockedChildrenIds.delete(this.id);
          parent.$TST.inheritSoundStateFromChildren();
        }
      } break;
      case Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER: {
        const parent = this.parent;
        if (parent) {
          parent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id);
          parent.$TST.inheritSoundStateFromChildren();
        }
      } break;

      case Constants.kTAB_STATE_SHARING_CAMERA: {
        const parent = this.parent;
        if (parent)
          parent.$TST.sharingCameraChildrenIds.delete(this.id);
        if (toTab && this.raw.sharingState)
          this.raw.sharingState.camera = false;
      } break;
      case Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSharingCameraChildrenIds.delete(this.id);
      } break;

      case Constants.kTAB_STATE_SHARING_MICROPHONE: {
        const parent = this.parent;
        if (parent)
          parent.$TST.sharingMicrophoneChildrenIds.delete(this.id);
        if (toTab && this.raw.sharingState)
          this.raw.sharingState.microphone = false;
      } break;
      case Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id);
      } break;

      case Constants.kTAB_STATE_SHARING_SCREEN: {
        const parent = this.parent;
        if (parent)
          parent.$TST.sharingScreenChildrenIds.delete(this.id);
        if (toTab && this.raw.sharingState)
          this.raw.sharingState.screen = undefined;
      } break;
      case Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER: {
        const parent = this.parent;
        if (parent)
          parent.$TST.maybeSharingScreenChildrenIds.delete(this.id);
      } break;

      case Constants.kTAB_STATE_GROUP_TAB:
        TabsStore.removeGroupTab(this.raw);
        break;

      case Constants.kTAB_STATE_PRIVATE_BROWSING:
        if (toTab)
          this.raw.incognito = false;
        break;

      case Constants.kTAB_STATE_ATTENTION:
        if (toTab)
          this.raw.attention = false;
        break;

      case Constants.kTAB_STATE_DISCARDED:
        if (toTab)
          this.raw.discarded = false;
        break;
    }

    if (TreeItem.allAutoStickyStates.has(state)) {
      if (this.canBecomeSticky)
        TabsStore.addCanBecomeStickyTab(this.raw);
      else
        TabsStore.removeCanBecomeStickyTab(this.raw);
    }

    if (modified &&
        state != Constants.kTAB_STATE_ACTIVE &&
        Constants.IS_BACKGROUND &&
        broadcast !== false)
      Tab.broadcastState(this.raw, {
        remove: [state],
      });
    if (permanently) {
      const states = await this.getPermanentStates();
      const index = states.indexOf(state);
      if (index > -1) {
        states.splice(index, 1);
        await browser.sessions.setTabValue(this.id, Constants.kPERSISTENT_STATES, states).catch(ApiTabs.createErrorSuppressor());
      }
    }
    if (modified) {
      this.invalidateCache();
      Tab.onStateChanged.dispatch(this.raw, state, false);
    }
  }

  async getPermanentStates() {
    const states = this.raw && await browser.sessions.getTabValue(this.id, Constants.kPERSISTENT_STATES).catch(ApiTabs.handleMissingTabError);
    // We need to cleanup invalid values stored accidentally.
    // See also: https://github.com/piroor/treestyletab/issues/2882
    return states && mapAndFilterUniq(states, state => state && String(state) || undefined) || [];
  }

  inheritSoundStateFromChildren() {
    if (!this.raw)
      return;

    // this is called too many times on a session restoration, so this should be throttled for better performance
    if (this.delayedInheritSoundStateFromChildren)
      clearTimeout(this.delayedInheritSoundStateFromChildren);

    this.delayedInheritSoundStateFromChildren = setTimeout(() => {
      this.delayedInheritSoundStateFromChildren = null;
      if (!TabsStore.ensureLivingItem(this.raw))
        return;

      const parent = this.parent;
      let modifiedCount = 0;

      const soundPlayingCount = this.soundPlayingChildrenIds.size + this.maybeSoundPlayingChildrenIds.size;
      if (soundPlayingCount != this.lastSoundStateCounts.soundPlaying) {
        this.lastSoundStateCounts.soundPlaying = soundPlayingCount;
        this.toggleState(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER, soundPlayingCount > 0);
        if (parent) {
          if (soundPlayingCount > 0)
            parent.$TST.maybeSoundPlayingChildrenIds.add(this.id);
          else
            parent.$TST.maybeSoundPlayingChildrenIds.delete(this.id);
        }
        modifiedCount++;
      }

      const mutedCount = this.mutedChildrenIds.size + this.maybeMutedChildrenIds.size;
      if (mutedCount != this.lastSoundStateCounts.muted) {
        this.lastSoundStateCounts.muted = mutedCount;
        this.toggleState(Constants.kTAB_STATE_HAS_MUTED_MEMBER, mutedCount > 0);
        if (parent) {
          if (mutedCount > 0)
            parent.$TST.maybeMutedChildrenIds.add(this.id);
          else
            parent.$TST.maybeMutedChildrenIds.delete(this.id);
        }
        modifiedCount++;
      }

      const autoplayBlockedCount = this.autoplayBlockedChildrenIds.size + this.maybeAutoplayBlockedChildrenIds.size;
      if (autoplayBlockedCount != this.lastSoundStateCounts.autoplayBlocked) {
        this.lastSoundStateCounts.autoplayBlocked = autoplayBlockedCount;
        this.toggleState(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER, autoplayBlockedCount > 0);
        if (parent) {
          if (autoplayBlockedCount > 0)
            parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id);
          else
            parent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id);
        }
        modifiedCount++;
      }

      if (modifiedCount == 0)
        return;

      if (parent)
        parent.$TST.inheritSoundStateFromChildren();

      SidebarConnection.sendMessage({
        type:                  Constants.kCOMMAND_NOTIFY_TAB_SOUND_STATE_UPDATED,
        windowId:              this.raw.windowId,
        tabId:                 this.id,
        hasSoundPlayingMember: this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER),
        hasMutedMember:        this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER),
        hasAutoplayBlockedMember: this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER),
      });
    }, 100);
  }

  inheritSharingStateFromChildren() {
    if (!this.raw)
      return;

    // this is called too many times on a session restoration, so this should be throttled for better performance
    if (this.delayedInheritSharingStateFromChildren)
      clearTimeout(this.delayedInheritSharingStateFromChildren);

    this.delayedInheritSharingStateFromChildren = setTimeout(() => {
      this.delayedInheritSharingStateFromChildren = null;
      if (!TabsStore.ensureLivingItem(this.raw))
        return;

      const parent = this.parent;
      let modifiedCount = 0;

      const sharingCameraCount = this.sharingCameraChildrenIds.size + this.maybeSharingCameraChildrenIds.size;
      if (sharingCameraCount != this.lastSharingStateCounts.sharingCamera) {
        this.lastSharingStateCounts.sharingCamera = sharingCameraCount;
        this.toggleState(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER, sharingCameraCount > 0);
        if (parent) {
          if (sharingCameraCount > 0)
            parent.$TST.maybeSharingCameraChildrenIds.add(this.id);
          else
            parent.$TST.maybeSharingCameraChildrenIds.delete(this.id);
        }
        modifiedCount++;
      }

      const sharingMicrophoneCount = this.sharingMicrophoneChildrenIds.size + this.maybeSharingMicrophoneChildrenIds.size;
      if (sharingMicrophoneCount != this.lastSharingStateCounts.sharingMicrophone) {
        this.lastSharingStateCounts.sharingMicrophone = sharingMicrophoneCount;
        this.toggleState(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER, sharingMicrophoneCount > 0);
        if (parent) {
          if (sharingMicrophoneCount > 0)
            parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id);
          else
            parent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id);
        }
        modifiedCount++;
      }

      const sharingScreenCount = this.sharingScreenChildrenIds.size + this.maybeSharingScreenChildrenIds.size;
      if (sharingScreenCount != this.lastSharingStateCounts.sharingScreen) {
        this.lastSharingStateCounts.sharingScreen = sharingScreenCount;
        this.toggleState(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER, sharingScreenCount > 0);
        if (parent) {
          if (sharingScreenCount > 0)
            parent.$TST.maybeSharingScreenChildrenIds.add(this.id);
          else
            parent.$TST.maybeSharingScreenChildrenIds.delete(this.id);
        }
        modifiedCount++;
      }

      if (modifiedCount == 0)
        return;

      if (parent)
        parent.$TST.inheritSharingStateFromChildren();

      SidebarConnection.sendMessage({
        type:     Constants.kCOMMAND_NOTIFY_TAB_SHARING_STATE_UPDATED,
        windowId: this.raw.windowId,
        tabId:    this.id,
        hasSharingCameraMember:     this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER),
        hasSharingMicrophoneMember: this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER),
        hasSharingScreenMember:     this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER),
      });
    }, 100);
  }


  onNativeGroupModified(oldGroupId) {
    if (this.raw.groupId == -1) {
      TabsStore.removeNativelyGroupedTab(this.raw);
    }
    else {
      TabsStore.addNativelyGroupedTab(this.raw);
    }

    this.setAttribute(Constants.kGROUP_ID, this.raw.groupId);

    const group = this.nativeTabGroup;
    if (group) {
      group.incognito = this.tab.incognito;
      group.$TST.reindex(this.raw);
    }

    if (oldGroupId && oldGroupId != -1) {
      TabGroup.get(oldGroupId)?.$TST.reindex();
    }

    Tab.onNativeGroupModified.dispatch(this.raw);
  }


  setAttribute(attribute, value) {
    super.setAttribute(attribute, value);
    this.invalidateCache();
  }

  removeAttribute(attribute) {
    super.removeAttribute(attribute);
    this.invalidateCache();
  }


  resolveOpened() {
    if (!mOpenedResolvers.has(this.id))
      return;
    for (const resolver of mOpenedResolvers.get(this.id)) {
      resolver.resolve();
    }
    mOpenedResolvers.delete(this.id);
  }
  rejectOpened() {
    if (!mOpenedResolvers.has(this.id))
      return;
    for (const resolver of mOpenedResolvers.get(this.id)) {
      resolver.reject();
    }
    mOpenedResolvers.delete(this.id);
  }

  apply(exported) { // not optimized and unsafe yet!
    if (!this.raw)
      return;

    TabsStore.removeTabFromIndexes(this.raw);

    for (const key of Object.keys(exported)) {
      if (key == '$TST')
        continue;
      if (key in this.raw)
        this.raw[key] = exported[key];
    }

    this.uniqueId = exported.$TST.uniqueId;
    this.promisedUniqueId = Promise.resolve(this.uniqueId);

    this.states     = new Set(exported.$TST.states);
    this.attributes = exported.$TST.attributes;

    this.parent   = exported.$TST.parentId;
    this.children = exported.$TST.childIds || [];

    TabsStore.updateIndexesForTab(this.raw);
  }

  async exportFullTreeItemProperties(fullExportedTreeItem, { isContextTab, permissions, cache } = {}) {
    const favIconUrl = await (
      (!permissions ||
       (!permissions.has(kPERMISSION_TABS) &&
        (!permissions.has(kPERMISSION_ACTIVE_TAB) ||
         !this.raw?.active))) ?
        null :
        (this.raw?.id in cache.effectiveFavIconUrls) ?
          cache.effectiveFavIconUrls[this.raw?.id] :
          this.raw?.favIconUrl?.startsWith('data:') ?
            this.raw?.favIconUrl :
            TabFavIconHelper.getLastEffectiveFavIconURL(this.raw).catch(ApiTabs.handleMissingTabError)
    );

    if (!(this.raw.id in cache.effectiveFavIconUrls))
      cache.effectiveFavIconUrls[this.raw.id] = favIconUrl;

    const allowedProperties = new Set([
      // basic tabs.Tab properties
      'active',
      'attention',
      'audible',
      'autoDiscardable',
      'discarded',
      'height',
      'hidden',
      'highlighted',
      //'id',
      'incognito',
      'index',
      'isArticle',
      'isInReaderMode',
      'lastAccessed',
      'mutedInfo',
      'openerTabId',
      'pinned',
      'selected',
      'sessionId',
      'sharingState',
      'status',
      'successorId',
      'width',
      //'windowId',
    ]);

    if (permissions.has(kPERMISSION_TABS) ||
        (permissions.has(kPERMISSION_ACTIVE_TAB) &&
         (this.raw.active ||
          isContextTab))) {
      // specially allowed with "tabs" or "activeTab" permission
      allowedProperties.add('favIconUrl');
      allowedProperties.add('title');
      allowedProperties.add('url');
      fullExportedTreeItem.effectiveFavIconUrl = favIconUrl;
    }
    if (permissions.has(kPERMISSION_COOKIES)) {
      allowedProperties.add('cookieStoreId');
      fullExportedTreeItem.cookieStoreName = this.raw.$TST.cookieStoreName;
    }

    for (const property of allowedProperties) {
      if (property in this.raw)
        fullExportedTreeItem[property] = this.raw[property];
    }
  }


  applyStatesToElement() {
    if (!this.element)
      return;

    super.applyStatesToElement();

    if (this.states.has(Constants.kTAB_STATE_HIGHLIGHTED)) {
      this.element.setAttribute('aria-selected', 'true');
    }
  }

  set favIconUrl(url) {
    if (this.element && 'favIconUrl' in this.element)
      this.element.favIconUrl = url;
    this.invalidateCache();
  }


  //===================================================================
  // class methods
  //===================================================================

  static track(tab) {
    const trackedTab = Tab.get(tab.id);
    if (!trackedTab ||
        !(tab.$TST instanceof Tab)) {
      new Tab(tab);
    }
    else {
      if (trackedTab)
        tab = trackedTab;
      const win = TabsStore.windows.get(tab.windowId);
      win.trackTab(tab);
    }
    return trackedTab || tab;
  }

  static untrack(tabId) {
    const tab = Tab.get(tabId);
    if (!tab) // already untracked
      return;
    const win = TabsStore.windows.get(tab.windowId);
    if (win)
      win.untrackTab(tabId);
  }

  static isTracked(tabId) {
    return TabsStore.tabs.has(tabId);
  }

  static get(tabId) {
    if (!tabId) {
      return null;
    }
    if (tabId && typeof tabId.color !== 'undefined') { // for backward compatibility
      return TabGroup.get(tabId.id);
    }
    return TabsStore.tabs.get(typeof tabId == 'number' ? tabId : tabId?.id);
  }

  static getByUniqueId(id) {
    if (!id)
      return null;
    return TabsStore.ensureLivingItem(TabsStore.tabsByUniqueId.get(id));
  }

  static needToWaitTracked(windowId) {
    if (windowId) {
      const tabs = mIncompletelyTrackedTabs.get(windowId);
      return tabs && tabs.size > 0;
    }
    for (const tabs of mIncompletelyTrackedTabs.values()) {
      if (tabs && tabs.size > 0)
        return true;
    }
    return false;
  }

  static async waitUntilTrackedAll(windowId, options = {}) {
    const tabSets = windowId ?
      [mIncompletelyTrackedTabs.get(windowId)] :
      [...mIncompletelyTrackedTabs.values()];
    return Promise.all(tabSets.map(tabs => {
      if (!tabs)
        return;
      let tabIds = Array.from(tabs, tab => tab.id);
      if (options.exceptionTabId)
        tabIds = tabIds.filter(id => id != options.exceptionTabId);
      return Tab.waitUntilTracked(tabIds, options);
    }));
  }

  static async waitUntilTracked(tabId, options = {}) {
    if (!tabId)
      return null;

    if (Array.isArray(tabId))
      return Promise.all(tabId.map(id => Tab.waitUntilTracked(id, options)));

    const windowId = TabsStore.getCurrentWindowId();
    if (windowId) {
      const tabs = TabsStore.removedTabsInWindow.get(windowId);
      if (tabs?.has(tabId))
        return null; // already removed tab
    }

    const key = `${tabId}:${!!options.element}`;
    if (mPromisedTrackedTabs.has(key))
      return mPromisedTrackedTabs.get(key);

    const promisedTracked = waitUntilTracked(tabId, options);
    mPromisedTrackedTabs.set(key, promisedTracked);
    return promisedTracked.then(tab => {
      // Don't claer the last promise, because it is required to process following "waitUntilTracked" callbacks sequentically.
      //if (mPromisedTrackedTabs.get(key) == promisedTracked)
      //  mPromisedTrackedTabs.delete(key);
      return tab;
    }).catch(_error => {
      //if (mPromisedTrackedTabs.get(key) == promisedTracked)
      //  mPromisedTrackedTabs.delete(key);
      return null;
    });
  }

  static needToWaitMoved(windowId) {
    if (windowId) {
      const tabs = mMovingTabs.get(windowId);
      return tabs && tabs.size > 0;
    }
    for (const tabs of mMovingTabs.values()) {
      if (tabs && tabs.size > 0)
        return true;
    }
    return false;
  }

  static async waitUntilMovedAll(windowId) {
    const tabSets = [];
    if (windowId) {
      tabSets.push(mMovingTabs.get(windowId));
    }
    else {
      for (const tabs of mMovingTabs.values()) {
        tabSets.push(tabs);
      }
    }
    return Promise.all(tabSets.map(tabs => tabs && Promise.all(tabs)));
  }

  static init(tab, options = {}) {
    log('initalize tab ', tab);
    if (!tab) {
      const error = new Error('Fatal error: invalid tab is given to Tab.init()');
      console.log(error, error.stack);
      throw error;
    }
    const trackedTab = Tab.get(tab.id);
    if (trackedTab)
      tab = trackedTab;
    tab.$TST = trackedTab?.$TST || new Tab(tab);
    tab.$TST.updateUniqueId().then(tab.$TST.onUniqueIdGenerated);

    if (tab.active)
      tab.$TST.addState(Constants.kTAB_STATE_ACTIVE);

    // When a new "child" tab was opened and the "parent" tab was closed
    // immediately by someone outside of TST, both new "child" and the
    // "parent" were closed by TST because all new tabs had
    // "subtree-collapsed" state initially and such an action was detected
    // as "closing of a collapsed tree".
    // The initial state was introduced in old versions, but I forgot why
    // it was required. "When new child tab is attached, collapse other
    // tree" behavior works as expected even if the initial state is not
    // there. Thus I remove the initial state for now, to avoid the
    // annoying problem.
    // See also: https://github.com/piroor/treestyletab/issues/2162
    // tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_COLLAPSED);

    Tab.onInitialized.dispatch(tab, options);

    if (options.existing) {
      tab.$TST.addState(Constants.kTAB_STATE_ANIMATION_READY);
      tab.$TST.opened = Promise.resolve(true).then(() => {
        tab.$TST.resolveOpened();
      });
      tab.$TST.temporaryMetadata.delete('opening');
      tab.$TST.temporaryMetadata.set('openedCompletely', true);
    }
    else {
      tab.$TST.temporaryMetadata.set('opening', true);
      tab.$TST.temporaryMetadata.delete('openedCompletely');
      tab.$TST.opened = new Promise((resolve, reject) => {
        tab.$TST.opening = false;
        const resolvers = mOpenedResolvers.get(tab.id) || new Set();
        resolvers.add({ resolve, reject });
        mOpenedResolvers.set(tab.id, resolvers);
      }).then(() => {
        tab.$TST.temporaryMetadata.set('openedCompletely', true);
      });
    }

    return tab;
  }

  static import(tab) {
    const existingTab = Tab.get(tab.id);
    if (!existingTab) {
      return Tab.init(tab);
    }
    existingTab.$TST.apply(tab);
    return existingTab;
  }

  //===================================================================
  // get single tab
  //===================================================================

  // Note that this function can return null if it is the first tab of
  // a new window opened by the "move tab to new window" command.
  static getActiveTab(windowId) {
    return TabsStore.ensureLivingItem(TabsStore.activeTabInWindow.get(windowId));
  }

  static getFirstTab(windowId) {
    return TabsStore.query({
      windowId,
      tabs:    TabsStore.livingTabsInWindow.get(windowId),
      living:  true,
      ordered: true
    });
  }

  static getLastTab(windowId) {
    return TabsStore.query({
      windowId,
      tabs:   TabsStore.livingTabsInWindow.get(windowId),
      living: true,
      last:   true
    });
  }

  static getFirstVisibleTab(windowId) { // visible, not-collapsed, not-hidden
    return TabsStore.query({
      windowId,
      tabs:    TabsStore.visibleTabsInWindow.get(windowId),
      visible: true,
      ordered: true
    });
  }

  static getLastVisibleTab(windowId) { // visible, not-collapsed, not-hidden
    return TabsStore.query({
      windowId,
      tabs:    TabsStore.visibleTabsInWindow.get(windowId),
      visible: true,
      last:    true,
    });
  }

  static getLastOpenedTab(windowId) {
    const tabs = Tab.getTabs(windowId);
    return tabs.length > 0 ?
      tabs.sort((a, b) => b.id - a.id)[0] :
      null ;
  }

  static getLastPinnedTab(windowId) { // visible, pinned
    return TabsStore.query({
      windowId,
      tabs:    TabsStore.pinnedTabsInWindow.get(windowId),
      living:  true,
      ordered: true,
      last:    true
    });
  }

  static getFirstUnpinnedTab(windowId) { // not-pinned
    return TabsStore.query({
      windowId,
      tabs:    TabsStore.unpinnedTabsInWindow.get(windowId),
      ordered: true
    });
  }

  static getLastUnpinnedTab(windowId) { // not-pinned
    return TabsStore.query({
      windowId,
      tabs:    TabsStore.unpinnedTabsInWindow.get(windowId),
      ordered: true,
      last:    true
    });
  }

  static getFirstNormalTab(windowId) { // visible, not-collapsed, not-pinned
    return TabsStore.query({
      windowId,
      tabs:    TabsStore.unpinnedTabsInWindow.get(windowId),
      normal:  true,
      ordered: true
    });
  }

  static getGroupTabForOpener(opener) {
    if (!opener)
      return null;
    TabsStore.assertValidTab(opener);
    const groupTab = TabsStore.query({
      windowId:   opener.windowId,
      tabs:       TabsStore.groupTabsInWindow.get(opener.windowId),
      living:     true,
      attributes: [
        Constants.kCURRENT_URI,
        new RegExp(`openerTabId=${opener.$TST.uniqueId.id}($|[#&])`)
      ]
    });
    if (!groupTab ||
        groupTab == opener ||
        groupTab.pinned == opener.pinned)
      return null;
    return groupTab;
  }

  static getOpenerFromGroupTab(groupTab) {
    if (!groupTab.$TST.isGroupTab)
      return null;
    TabsStore.assertValidTab(groupTab);
    const openerTabId = (new URL(groupTab.url)).searchParams.get('openerTabId');
    const openerTab = Tab.getByUniqueId(openerTabId);
    if (!openerTab ||
        openerTab == groupTab ||
        openerTab.pinned == groupTab.pinned)
      return null;
    return openerTab;
  }

  static getSubstanceFromAliasGroupTab(groupTab) {
    if (!groupTab.$TST.isGroupTab)
      return null;
    TabsStore.assertValidTab(groupTab);
    const aliasTabId = (new URL(groupTab.url)).searchParams.get('aliasTabId');
    const aliasTab = Tab.getByUniqueId(aliasTabId);
    if (!aliasTab ||
        aliasTab == groupTab ||
        aliasTab.pinned == groupTab.pinned)
      return null;
    return aliasTab;
  }

  //===================================================================
  // grap tabs
  //===================================================================

  static getActiveTabs() {
    return Array.from(TabsStore.activeTabInWindow.values(), TabsStore.ensureLivingItem);
  }

  static getAllTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:     TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId),
      living:   true,
      ordered:  true,
      ...options
    });
  }

  static getTabAt(windowId, index) {
    const tabs    = TabsStore.livingTabsInWindow.get(windowId);
    const allTabs = TabsStore.windows.get(windowId).tabs;
    return TabsStore.query({
      windowId,
      tabs,
      living:       true,
      fromIndex:    Math.max(0, index - (allTabs.size - tabs.size)),
      logicalIndex: index,
      first:        true
    });
  }

  static getTabs(windowId = null, options = {}) { // only visible, including collapsed and pinned
    return TabsStore.queryAll({
      windowId,
      tabs:         TabsStore.getTabsMap(TabsStore.controllableTabsInWindow, windowId),
      controllable: true,
      ordered:      true,
      ...options
    });
  }

  static getTabsBetween(begin, end) {
    if (!begin || !TabsStore.ensureLivingItem(begin) ||
        !end || !TabsStore.ensureLivingItem(end))
      throw new Error('getTabsBetween requires valid two tabs');
    if (begin.windowId != end.windowId)
      throw new Error('getTabsBetween requires two tabs in same window');

    if (begin == end)
      return [];
    if (begin.index > end.index)
      [begin, end] = [end, begin];
    return TabsStore.queryAll({
      windowId: begin.windowId,
      tabs:     TabsStore.getTabsMap(TabsStore.controllableTabsInWindow, begin.windowId),
      id:       (id => id != begin.id && id != end.id),
      fromId:   begin.id,
      toId:     end.id
    });
  }

  static getNormalTabs(windowId = null, options = {}) { // only visible, including collapsed, not pinned
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.unpinnedTabsInWindow, windowId),
      normal:  true,
      ordered: true,
      ...options
    });
  }

  static getVisibleTabs(windowId = null, options = {}) { // visible, not-collapsed, not-hidden
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.visibleTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getHiddenTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId),
      living:  true,
      ordered: true,
      hidden:  true,
      ...options
    });
  }

  static getPinnedTabs(windowId = null, options = {}) { // visible, pinned
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.pinnedTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getUnpinnedTabs(windowId = null, options = {}) { // visible, not pinned
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.unpinnedTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getRootTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:         TabsStore.getTabsMap(TabsStore.rootTabsInWindow, windowId),
      controllable: true,
      ordered:      true,
      ...options
    });
  }

  static getLastRootTab(windowId, options = {}) {
    const tabs = Tab.getRootTabs(windowId, options);
    return tabs[tabs.length - 1];
  }

  static collectRootTabs(tabs) {
    const tabsSet = new Set(tabs);
    return tabs.filter(tab => {
      if (!TabsStore.ensureLivingItem(tab))
        return false;
      const parent = tab.$TST.parent;
      return !parent || !tabsSet.has(parent);
    });
  }

  static getSubtreeCollapsedTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:     TabsStore.getTabsMap(TabsStore.subtreeCollapsableTabsInWindow, windowId),
      living:   true,
      hidden:   false,
      ordered:  true,
      ...options
    });
  }

  static getGroupTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.groupTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getLoadingTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.loadingTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getDraggingTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.draggingTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getRemovingTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.removingTabsInWindow, windowId),
      ordered: true,
      ...options
    });
  }

  static getDuplicatingTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.duplicatingTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getHighlightedTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.highlightedTabsInWindow, windowId),
      living:  true,
      ordered: true,
      ...options
    });
  }

  static getSelectedTabs(windowId = null, options = {}) {
    const tabs = TabsStore.getTabsMap(TabsStore.selectedTabsInWindow, windowId);
    const selectedTabs = TabsStore.queryAll({
      windowId,
      tabs,
      living:  true,
      ordered: true,
      ...options
    });
    const highlightedTabs = TabsStore.getTabsMap(TabsStore.highlightedTabsInWindow, windowId);
    if (!highlightedTabs ||
        highlightedTabs.size < 2)
      return selectedTabs;

    if (options.iterator)
      return (function* () {
        const alreadyReturnedTabs = new Set();
        for (const tab of selectedTabs) {
          yield tab;
          alreadyReturnedTabs.add(tab);
        }
        for (const tab of highlightedTabs.values()) {
          if (!alreadyReturnedTabs.has(tab))
            yield tab;
        }
      })();
    else
      return TreeItem.sort(Array.from(new Set([...selectedTabs, ...Array.from(highlightedTabs.values())])));
  }

  static getNeedToBeSynchronizedTabs(windowId = null, options = {}) {
    return TabsStore.queryAll({
      windowId,
      tabs:    TabsStore.getTabsMap(TabsStore.unsynchronizedTabsInWindow, windowId),
      visible: true,
      ...options
    });
  }

  static hasNeedToBeSynchronizedTab(windowId) {
    return !!TabsStore.query({
      windowId,
      tabs:     TabsStore.getTabsMap(TabsStore.unsynchronizedTabsInWindow, windowId),
      visible:  true
    });
  }

  static hasLoadingTab(windowId) {
    return !!TabsStore.query({
      windowId,
      tabs:     TabsStore.getTabsMap(TabsStore.loadingTabsInWindow, windowId),
      visible:  true
    });
  }

  static hasDuplicatedTabs(windowId, options = {}) {
    const tabs = TabsStore.queryAll({
      windowId,
      tabs:   TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId),
      living: true,
      ...options,
      iterator: true
    });
    const tabKeys = new Set();
    for (const tab of tabs) {
      const key = `${tab.cookieStoreId}\n${tab.url}`;
      if (tabKeys.has(key))
        return true;
      tabKeys.add(key);
    }
    return false;
  }

  static hasMultipleTabs(windowId, options = {}) {
    const tabs = TabsStore.queryAll({
      windowId,
      tabs:   TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId),
      living: true,
      ...options,
      iterator: true
    });
    let count = 0;
    // eslint-disable-next-line no-unused-vars
    for (const tab of tabs) {
      count++;
      if (count > 1)
        return true;
    }
    return false;
  }

  // "Recycled tab" is an existing but reused tab for session restoration.
  static getRecycledTabs(windowId = null, options = {}) {
    const userNewTabUrls = configs.guessNewOrphanTabAsOpenedByNewTabCommandUrl.split('|').map(part => sanitizeForRegExpSource(part.trim())).join('|');
    return TabsStore.queryAll({
      windowId,
      tabs:       TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId),
      living:     true,
      states:     [Constants.kTAB_STATE_RESTORED, false],
      attributes: [Constants.kCURRENT_URI, new RegExp(`^(|${userNewTabUrls}|about:newtab|about:blank|about:privatebrowsing)$`)],
      ...options
    });
  }

  //===================================================================
  // utilities
  //===================================================================

  static bufferedTooltipTextChanges = new Map();
  static broadcastTooltipText(tabs) {
    if (!Constants.IS_BACKGROUND ||
        !Tab.broadcastTooltipText.enabled)
      return;

    if (!Array.isArray(tabs))
      tabs = [tabs];

    if (tabs.length == 0)
      return;

    for (const tab of tabs) {
      Tab.bufferedTooltipTextChanges.set(tab.id, {
        windowId: tab.windowId,
        tabId:    tab.id,
        high:     tab.$TST.highPriorityTooltipText,
        low:      tab.$TST.lowPriorityTooltipText,
      });
    }

    const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`;
    Tab.broadcastTooltipText.triedAt = triedAt;
    (Constants.IS_BACKGROUND ?
      setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document.
      window.requestAnimationFrame)(() => {
      if (Tab.broadcastTooltipText.triedAt != triedAt)
        return;

      // Let's flush buffered changes!
      const messageForWindows = new Map();
      for (const change of Tab.bufferedTooltipTextChanges.values()) {
        const message = messageForWindows.get(change.windowId) || {
          type:     Constants.kCOMMAND_BROADCAST_TAB_TOOLTIP_TEXT,
          windowId: change.windowId,
          tabIds:   [],
          changes:  [],
        };
        message.tabIds.push(change.tabId);
        message.changes.push(change);
      }
      for (const message of messageForWindows) {
        SidebarConnection.sendMessage(message);
      }
      Tab.bufferedTooltipTextChanges.clear();
    }, 0);
  }

  static bufferedStatesChanges = new Map();
  static broadcastState(tabs, { add, remove } = {}) {
    if (!Constants.IS_BACKGROUND ||
        !Tab.broadcastState.enabled)
      return;

    if (!Array.isArray(tabs))
      tabs = [tabs];

    if (tabs.length == 0)
      return;

    for (const tab of tabs) {
      const message = Tab.bufferedStatesChanges.get(tab.id) || {
        windowId: tab.windowId,
        tabId:    tab.id,
        add:      new Set(),
        remove:   new Set(),
      };
      if (add)
        for (const state of add) {
          message.add.add(state);
          message.remove.delete(state);
        }
      if (remove)
        for (const state of remove) {
          message.add.delete(state);
          message.remove.add(state);
        }

      Tab.bufferedStatesChanges.set(tab.id, message);
    }

    const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`;
    Tab.broadcastState.triedAt = triedAt;
    (Constants.IS_BACKGROUND ?
      setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document.
      window.requestAnimationFrame)(() => {
      if (Tab.broadcastState.triedAt != triedAt)
        return;

      // Let's flush buffered changes!

      // Unify buffered changes only if same type changes are consecutive.
      // Otherwise the order of changes would be mixed and things may become broken.
      const unifiedMessages = [];
      let lastKey;
      let unifiedMessage = null;
      for (const message of Tab.bufferedStatesChanges.values()) {
        const key = `${message.windowId}/add:${[...message.add]}/remove:${[...message.remove]}`;
        if (key != lastKey) {
          if (unifiedMessage)
            unifiedMessages.push(unifiedMessage);
          unifiedMessage = null;
        }
        lastKey = key;
        unifiedMessage = unifiedMessage || {
          type:     Constants.kCOMMAND_BROADCAST_TAB_STATE,
          windowId: message.windowId,
          tabIds:   new Set(),
          add:      message.add,
          remove:   message.remove,
        };
        unifiedMessage.tabIds.add(message.tabId);
      }
      if (unifiedMessage)
        unifiedMessages.push(unifiedMessage);
      Tab.bufferedStatesChanges.clear();

      // SidebarConnection.sendMessage() has its own bulk-send mechanism,
      // so we don't need to bundle them like an array.
      for (const message of unifiedMessages) {
        SidebarConnection.sendMessage({
          type:     Constants.kCOMMAND_BROADCAST_TAB_STATE,
          windowId: message.windowId,
          tabIds:   [...message.tabIds],
          add:      [...message.add],
          remove:   [...message.remove],
        });
      }
    }, 0);
  }

  static getOtherTabs(windowId, ignoreTabs, options = {}) {
    const query = {
      windowId: windowId,
      tabs:     TabsStore.livingTabsInWindow.get(windowId),
      ordered:  true
    };
    if (Array.isArray(ignoreTabs) &&
        ignoreTabs.length > 0)
      query['!id'] = ignoreTabs.map(tab => tab.id);
    return TabsStore.queryAll({ ...query, ...options });
  };

  static getIndex(tab, { ignoreTabs } = {}) {
    if (!TabsStore.ensureLivingItem(tab))
      return -1;
    TabsStore.assertValidTab(tab);
    return Tab.getOtherTabs(tab.windowId, ignoreTabs).indexOf(tab);
  }

  static calculateNewTabIndex({ insertAfter, insertBefore, ignoreTabs } = {}) {
    // We need to calculate new index based on "insertAfter" at first, to avoid
    // placing of the new tab after hidden tabs (too far from the location it
    // should be.)
    if (insertAfter)
      return Tab.getIndex(insertAfter, { ignoreTabs }) + 1;
    if (insertBefore)
      return Tab.getIndex(insertBefore, { ignoreTabs });
    return -1;
  }

  static async doAndGetNewTabs(asyncTask, windowId) {
    const tabsQueryOptions = {
      windowType: 'normal'
    };
    if (windowId) {
      tabsQueryOptions.windowId = windowId;
    }
    const beforeTabs = await browser.tabs.query(tabsQueryOptions).catch(ApiTabs.createErrorHandler());
    const beforeIds  = mapAndFilterUniq(beforeTabs, tab => tab.id, { set: true });
    await asyncTask();
    const afterTabs = await browser.tabs.query(tabsQueryOptions).catch(ApiTabs.createErrorHandler());
    const addedTabs = mapAndFilter(afterTabs,
                                   tab => !beforeIds.has(tab.id) && Tab.get(tab.id) || undefined);
    return addedTabs;
  }

  static dumpAll(windowId) {
    if (!configs.debug)
      return;
    let output = 'dumpAllTabs';
    for (const tab of Tab.getAllTabs(windowId, {iterator: true })) {
      output += '\n' + toLines([...tab.$TST.ancestors.reverse(), tab],
                               tab => `${tab.id}${tab.pinned ? ' [pinned]' : ''}`,
                               ' => ');
    }
    log(output);
  }
}


const mWaitingTasks = new Map();

function destroyWaitingTabTask(task) {
  const tasks = mWaitingTasks.get(task.tabId);
  if (tasks)
    tasks.delete(task);

  if (task.timeout)
    clearTimeout(task.timeout);

  const resolve     = task.resolve;
  const stack       = task.stack;

  task.tabId       = undefined;
  task.resolve     = undefined;
  task.timeout     = undefined;
  task.stack       = undefined;

  return { resolve, stack };
}

function onWaitingTabTracked(tab) {
  if (!tab)
    return;

  const tasks = mWaitingTasks.get(tab.id);
  if (!tasks)
    return;

  mWaitingTasks.delete(tab.id);

  for (const task of tasks) {
    tasks.delete(task);
    const { resolve } = destroyWaitingTabTask(task);
    if (!resolve)
      continue;
    resolve(tab);
  }
}
TreeItem.onElementBound.addListener(onWaitingTabTracked);
Tab.onTracked.addListener(onWaitingTabTracked);

function onWaitingTabDestroyed(tab) {
  if (!tab)
    return;

  const tasks = mWaitingTasks.get(tab.id);
  if (!tasks)
    return;

  mWaitingTasks.delete(tab.id);

  const scope = TabsStore.getCurrentWindowId() || 'bg';
  for (const task of tasks) {
    tasks.delete(task);
    const { resolve, stack } = destroyWaitingTabTask(task);
    if (!resolve)
      continue;

    log(`Tab.waitUntilTracked: ${tab.id} is destroyed while waiting (in ${scope})\n${stack}`);
    resolve(null);
  }
}
Tab.onDestroyed.addListener(onWaitingTabDestroyed);

function onWaitingTabRemoved(removedTabId, _removeInfo) {
  const tasks = mWaitingTasks.get(removedTabId);
  if (!tasks)
    return;

  mWaitingTasks.delete(removedTabId);

  const scope = TabsStore.getCurrentWindowId() || 'bg';
  for (const task of tasks) {
    tasks.delete(task);
    const { resolve, stack } = destroyWaitingTabTask(task);
    if (!resolve)
      continue;

    log(`Tab.waitUntilTracked: ${removedTabId} is removed while waiting (in ${scope})\n${stack}`);
    resolve(null);
  }
}
browser.tabs.onRemoved.addListener(onWaitingTabRemoved);

async function waitUntilTracked(tabId, options = {}) {
  if (!tabId) {
    return null;
  }
  const stack = configs.debug && new Error().stack;
  const tab = Tab.get(tabId);
  if (tab) {
    onWaitingTabTracked(tab);
    if (options.element)
      return tab.$TST.promisedElement;
    return tab;
  }
  const tasks = mWaitingTasks.get(tabId) || new Set();
  const task = {
    tabId,
    stack,
  };
  tasks.add(task);
  mWaitingTasks.set(tabId, tasks);
  return new Promise((resolve, _reject) => {
    task.resolve = resolve;
    task.timeout = setTimeout(() => {
      const { resolve } = destroyWaitingTabTask(task);
      if (resolve) {
        log(`Tab.waitUntilTracked for ${tabId} is timed out (in ${TabsStore.getCurrentWindowId() || 'bg'})\b${stack}`);
        resolve(null);
      }
    }, configs.maximumDelayUntilTabIsTracked); // Tabs.moveTabs() between windows may take much time
    browser.tabs.get(tabId).catch(_error => null).then(tab => {
      if (tab) {
        if (Tab.get(tabId))
          onWaitingTabTracked(tab);
        return;
      }
      const { resolve } = destroyWaitingTabTask(task);
      if (resolve) {
        log('waitUntilTracked was called for unexisting tab');
        resolve(null);
      }
    });
  }).then(() => destroyWaitingTabTask(task));
}

Tab.broadcastTooltipText.enabled = false;
Tab.broadcastState.enabled = false;

// utility
TreeItem.get = item => {
  if (!item) {
    return null;
  }
  switch (item?.type) {
    case TreeItem.TYPE_TAB:
      return Tab.get(item.id);

    case TreeItem.TYPE_GROUP:
      return TabGroup.get(item.id);

    case TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER:
      return TabGroup.get(item.id).$TST.collapsedMembersCounterItem;

    default:
      return TabGroup.get(item) || Tab.get(item);
  }
};
