/*
# 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 {
  log as internalLogger,
  wait,
  configs,
  sanitizeForHTMLText,
  waitUntilStartupOperationsUnblocked,
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as Constants from '/common/constants.js';
import * as ContextualIdentities from '/common/contextual-identities.js';
import * as Dialog from '/common/dialog.js';
import * as Permissions from '/common/permissions.js';
import * as SidebarConnection from '/common/sidebar-connection.js';
import * as Sync from '/common/sync.js';
import * as TabsStore from '/common/tabs-store.js';
import * as TabsUpdate from '/common/tabs-update.js';
import * as TSTAPI from '/common/tst-api.js';
import * as UniqueId from '/common/unique-id.js';
import '/common/bookmark.js'; // we need to load this once in the background page to register the global listener

import MetricsData from '/common/MetricsData.js';
import { Tab, TabGroup } from '/common/TreeItem.js';
import Window from '/common/Window.js';

import * as ApiTabsListener from './api-tabs-listener.js';
import * as BackgroundCache from './background-cache.js';
import * as Commands from './commands.js';
import * as ContextMenu from './context-menu.js';
import * as Migration from './migration.js';
import * as NativeTabGroups from './native-tab-groups.js';
import * as TabContextMenu from './tab-context-menu.js';
import * as Tree from './tree.js';
import * as TreeStructure from './tree-structure.js';
import './browser-action-menu.js';
import './duplicated-tab-detection.js';
import './successor-tab.js';

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

// This needs to be large enough for bulk updates on multiple tabs.
const DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS = 250;

export const onInit    = new EventListenerManager();
export const onBuilt   = new EventListenerManager();
export const onReady   = new EventListenerManager();
export const onDestroy = new EventListenerManager();
export const onTreeCompletelyAttached = new EventListenerManager();

export const instanceId = `${Date.now()}-${parseInt(Math.random() * 65000)}`;

const mDarkModeMatchMedia = window.matchMedia('(prefers-color-scheme: dark)');

let mInitialized = false;
const mPreloadedCaches = new Map();

async function getAllWindows() {
  const [windows, tabGroups] = await Promise.all([
    browser.windows.getAll({
      populate:    true,
      // We need to track all type windows because
      // popup windows can be destination of tabs.move().
      // See also: https://github.com/piroor/treestyletab/issues/3311
      windowTypes: ['normal', 'panel', 'popup'],
    }).catch(ApiTabs.createErrorHandler()),
    browser.tabGroups.query({}),
  ]);

  const groupsByWindow = new Map();
  for (const group of tabGroups) {
    const groupsInWindow = groupsByWindow.get(group.windowId) || [];
    groupsInWindow.push(group);
    groupsByWindow.set(group.windowId, groupsInWindow)
  }

  for (const win of windows) {
    win.tabGroups = groupsByWindow.get(win.id) || [];
  }

  return windows;
}

log('init: Start queuing of messages notified via WE APIs');
ApiTabsListener.init();
const promisedRestored = waitUntilCompletelyRestored(); // this must be called synchronosly

export async function init() {
  log('init: start');
  MetricsData.add('init: start');
  window.addEventListener('pagehide', destroy, { once: true });

  onInit.dispatch();
  SidebarConnection.init();

  // Read caches from existing tabs at first, for better performance.
  // Those promises will be resolved while waiting for waitUntilCompletelyRestored().
  getAllWindows()
    .then(windows => {
      for (const win of windows) {
        browser.sessions.getWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_TABS)
          .catch(ApiTabs.createErrorSuppressor())
          .then(cache => mPreloadedCaches.set(`window-${win.id}`, cache));
        const tab = win.tabs[0];
        browser.sessions.getTabValue(tab.id, Constants.kWINDOW_STATE_CACHED_TABS)
          .catch(ApiTabs.createErrorSuppressor())
          .then(cache => mPreloadedCaches.set(`tab-${tab.id}`, cache));
      }
    });

  let promisedWindows;
  log('init: Getting existing windows and tabs');
  await MetricsData.addAsync('init: waiting for waitUntilCompletelyRestored, ContextualIdentities.init and configs.$loaded', Promise.all([
    promisedRestored.then(() => {
      // don't wait at here for better performance
      promisedWindows = getAllWindows();
    }),
    ContextualIdentities.init(),
    configs.$loaded.then(waitUntilStartupOperationsUnblocked),
  ]));
  MetricsData.add('init: prepare');
  EventListenerManager.debug = configs.debug;

  Migration.migrateConfigs();
  Migration.migrateBookmarkUrls();
  configs.grantedRemovingTabIds = []; // clear!
  MetricsData.add('init: Migration.migrateConfigs');

  updatePanelUrl();

  const windows = await MetricsData.addAsync('init: getting all tabs across windows', promisedWindows); // wait at here for better performance
  const restoredFromCache = await MetricsData.addAsync('init: rebuildAll', rebuildAll(windows));
  mPreloadedCaches.clear();
  await MetricsData.addAsync('init: TreeStructure.loadTreeStructure', TreeStructure.loadTreeStructure(windows, restoredFromCache));

  log('init: Start to process messages including queued ones');
  ApiTabsListener.start();

  Migration.tryNotifyNewFeatures();

  ContextualIdentities.startObserve();
  onBuilt.dispatch(); // after this line, this master process may receive "kCOMMAND_PING_TO_BACKGROUND" requests from sidebars.
  MetricsData.add('init: started listening');

  TabContextMenu.init();
  ContextMenu.init().then(() => updateIconForBrowserTheme());
  MetricsData.add('init: started initializing of context menu');

  Permissions.clearRequest();

  for (const windowId of restoredFromCache.keys()) {
    if (!restoredFromCache[windowId])
      BackgroundCache.reserveToCacheTree(windowId, 'initialize');
    TabsUpdate.completeLoadingTabs(windowId);
  }

  for (const tab of Tab.getAllTabs(null, { iterator: true })) {
    updateSubtreeCollapsed(tab);
  }
  for (const tab of Tab.getActiveTabs()) {
    for (const ancestor of tab.$TST.ancestors) {
      Tree.collapseExpandTabAndSubtree(ancestor, {
        collapsed: false,
        justNow:   true
      });
    }
  }

  // we don't need to await that for the initialization of TST itself.
  MetricsData.addAsync('init: initializing API for other addons', TSTAPI.initAsBackend());

  mInitialized = true;
  UniqueId.readyToDetectDuplicatedTab();
  Tab.broadcastState.enabled = true;
  onReady.dispatch();
  BackgroundCache.activate();
  TreeStructure.startTracking();

  Sync.init();

  await NativeTabGroups.startToMaintainTree();

  await MetricsData.addAsync('init: exporting tabs to sidebars', notifyReadyToSidebars());

  log(`Startup metrics for ${TabsStore.tabs.size} tabs: `, MetricsData.toString());
}

async function notifyReadyToSidebars() {
  log('notifyReadyToSidebars: start');
  const promisedResults = [];
  for (const win of TabsStore.windows.values()) {
    // Send PING to all windows whether they are detected as opened or not, because
    // the connection may be established before this background page starts listening
    // of messages from sidebar pages.
    // See also: https://github.com/piroor/treestyletab/issues/2200
    TabsUpdate.completeLoadingTabs(win.id); // failsafe
    log(`notifyReadyToSidebars: to ${win.id}`);
    promisedResults.push(browser.runtime.sendMessage({
      type:     Constants.kCOMMAND_NOTIFY_BACKGROUND_READY,
      windowId: win.id,
      exported: win.export(true), // send tabs together to optimizie further initialization tasks in the sidebar
    }).catch(ApiTabs.createErrorSuppressor()));
  }
  return Promise.all(promisedResults);
}

async function updatePanelUrl(theme) {
  const url = new URL(Constants.kSHORTHAND_URIS.tabbar);
  url.searchParams.set('style', configs.style);
  url.searchParams.set('reloadMaskImage', !!configs.enableWorkaroundForBug1763420_reloadMaskImage);
  if (!theme)
    theme = await browser.theme.getCurrent();
  if (browser.sidebarAction)
    browser.sidebarAction.setPanel({ panel: url.href });
/*
  const url = new URL(Constants.kSHORTHAND_URIS.tabbar);
  url.searchParams.set('style', configs.style);
  if (browser.sidebarAction)
    browser.sidebarAction.setPanel({ panel: url.href });
*/
}

async function waitUntilCompletelyRestored() {
  log('waitUntilCompletelyRestored');
  const initialTabs = await browser.tabs.query({});
  await Promise.all([
    MetricsData.addAsync('waitUntilCompletelyRestored: existing tabs ', Promise.all(
      initialTabs.map(tab => waitUntilPersistentIdBecomeAvailable(tab.id).catch(_error => {}))
    )),
    MetricsData.addAsync('waitUntilCompletelyRestored: opening tabs ', new Promise((resolve, _reject) => {
      let promises = [];
      let timeout;
      let resolver;
      let onNewTabRestored = async (tab, _info = {}) => {
        clearTimeout(timeout);
        log('new restored tab is detected.');
        promises.push(waitUntilPersistentIdBecomeAvailable(tab.id).catch(_error => {}));
        // Read caches from restored tabs while waiting, for better performance.
        browser.sessions.getWindowValue(tab.windowId, Constants.kWINDOW_STATE_CACHED_TABS)
          .catch(ApiTabs.createErrorSuppressor())
          .then(cache => mPreloadedCaches.set(`window-${tab.windowId}`, cache));
        browser.sessions.getTabValue(tab.id, Constants.kWINDOW_STATE_CACHED_TABS)
          .catch(ApiTabs.createErrorSuppressor())
          .then(cache => mPreloadedCaches.set(`tab-${tab.id}`, cache));
        //uniqueId = uniqueId?.id || '?'; // not used
        timeout = setTimeout(resolver, 100);
      };
      browser.tabs.onCreated.addListener(onNewTabRestored);
      resolver = (async () => {
        log(`timeout: all ${promises.length} tabs are restored. `, promises);
        browser.tabs.onCreated.removeListener(onNewTabRestored);
        timeout = resolver = onNewTabRestored = undefined;
        await Promise.all(promises);
        promises = undefined;
        resolve();
      });
      timeout = setTimeout(resolver, 500);
    })),
  ]);
}
async function waitUntilPersistentIdBecomeAvailable(tabId, retryCount = 0) {
  if (retryCount > 10) {
    console.log(`could not get persistent ID for ${tabId}`);
    return false;
  }
  const uniqueId = await browser.sessions.getTabValue(tabId, Constants.kPERSISTENT_ID);
  if (!uniqueId)
    return wait(100).then(() => waitUntilPersistentIdBecomeAvailable(tabId, retryCount + 1));
  return true;
}

function destroy() {
  browser.runtime.sendMessage({
    type:  TSTAPI.kUNREGISTER_SELF
  }).catch(ApiTabs.createErrorSuppressor());

  // This API doesn't work as expected because it is not notified to
  // other addons actually when browser.runtime.sendMessage() is called
  // on pagehide or something unloading event.
  TSTAPI.broadcastMessage({
    type: TSTAPI.kNOTIFY_SHUTDOWN
  }).catch(ApiTabs.createErrorSuppressor());

  onDestroy.dispatch();
  ApiTabsListener.destroy();
  ContextualIdentities.endObserve();
}

async function rebuildAll(windows) {
  if (!windows)
    windows = await getAllWindows();
  const restoredFromCache = new Map();
  await Promise.all(windows.map(async win => {
    await MetricsData.addAsync(`rebuildAll: tabs in window ${win.id}`, async () => {
      let trackedWindow = TabsStore.windows.get(win.id);
      if (!trackedWindow)
        trackedWindow = Window.init(win.id, win.tabGroups.map(TabGroup.init));

      for (const tab of win.tabs) {
        Tab.track(tab);
        Tab.init(tab, { existing: true });
        tryStartHandleAccelKeyOnTab(tab);
      }
      try {
        if (configs.useCachedTree) {
          log(`trying to restore window ${win.id} from cache`);
          const restored = await MetricsData.addAsync(`rebuildAll: restore tabs in window ${win.id} from cache`, BackgroundCache.restoreWindowFromEffectiveWindowCache(win.id, {
            owner: win.tabs[win.tabs.length - 1],
            tabs:  win.tabs,
            caches: mPreloadedCaches
          }));
          restoredFromCache.set(win.id, restored);
          log(`window ${win.id}: restored from cache?: `, restored);
          if (restored)
            return;
        }
      }
      catch(e) {
        log(`failed to restore tabs for ${win.id} from cache `, e);
      }
      try {
        log(`build tabs for ${win.id} from scratch`);
        Window.init(win.id, win.tabGroups.map(TabGroup.init));
        const promises = [];
        for (let tab of win.tabs) {
          tab = Tab.get(tab.id);
          tab.$TST.clear(); // clear dirty restored states
          promises.push(
            tab.$TST.getPermanentStates()
              .then(states => {
                tab.$TST.states = new Set(states);
                tab.$TST.addState(Constants.kTAB_STATE_PENDING);
              })
              .catch(console.error)
              .then(() => {
                TabsUpdate.updateTab(tab, tab, { forceApply: true });
              })
          );
          tryStartHandleAccelKeyOnTab(tab);
        }
        await Promise.all(promises);
      }
      catch(e) {
        log(`failed to build tabs for ${win.id}`, e);
      }
      restoredFromCache.set(win.id, false);
    });
    for (const tab of Tab.getGroupTabs(win.id, { iterator: true })) {
      if (!tab.discarded)
        tab.$TST.temporaryMetadata.set('shouldReloadOnSelect', true);
    }
  }));
  return restoredFromCache;
}

export async function reload(options = {}) {
  mPreloadedCaches.clear();
  for (const win of TabsStore.windows.values()) {
    win.clear();
  }
  TabsStore.clear();
  const windows = await getAllWindows();
  await MetricsData.addAsync('reload: rebuildAll', rebuildAll(windows));
  await MetricsData.addAsync('reload: TreeStructure.loadTreeStructure', TreeStructure.loadTreeStructure(windows));
  if (!options.all)
    return;
  for (const win of TabsStore.windows.values()) {
    if (!SidebarConnection.isOpen(win.id))
      continue;
    log('reload all sidebars: ', new Error().stack);
    browser.runtime.sendMessage({
      type: Constants.kCOMMAND_RELOAD
    }).catch(ApiTabs.createErrorSuppressor());
  }
}

export async function tryStartHandleAccelKeyOnTab(tab) {
  if (!TabsStore.ensureLivingItem(tab))
    return;
  const granted = await Permissions.isGranted(Permissions.ALL_URLS);
  if (!granted ||
      /^(about|chrome|resource):/.test(tab.url))
    return;
  try {
    //log(`tryStartHandleAccelKeyOnTab: initialize tab ${tab.id}`);
    if (browser.scripting) // Manifest V3
      browser.scripting.executeScript({
        target: {
          tabId: tab.id,
          allFrames: true,
        },
        files:  ['/common/handle-accel-key.js'],
      }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError));
    else
      browser.tabs.executeScript(tab.id, {
        file:            '/common/handle-accel-key.js',
        allFrames:       true,
        matchAboutBlank: true,
        runAt:           'document_start'
      }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError));
  }
  catch(error) {
    console.log(error);
  }
}

export function reserveToUpdateInsertionPosition(tabOrTabs) {
  const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ;
  for (const tab of tabs) {
    if (!TabsStore.ensureLivingItem(tab))
      continue;
    const reserved = reserveToUpdateInsertionPosition.reserved.get(tab.windowId) || {
      timer: null,
      tabs:  new Set()
    };
    if (reserved.timer)
      clearTimeout(reserved.timer);
    reserved.tabs.add(tab);
    reserved.timer = setTimeout(() => {
      reserveToUpdateInsertionPosition.reserved.delete(tab.windowId);
      for (const tab of reserved.tabs) {
        if (!tab.$TST)
          continue;
        updateInsertionPosition(tab);
      }
    }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS);
    reserveToUpdateInsertionPosition.reserved.set(tab.windowId, reserved);
  }
}
reserveToUpdateInsertionPosition.reserved = new Map();

async function updateInsertionPosition(tab) {
  if (!TabsStore.ensureLivingItem(tab))
    return;

  const prev = tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab;
  if (prev)
    browser.sessions.setTabValue(
      tab.id,
      Constants.kPERSISTENT_INSERT_AFTER,
      prev.$TST.uniqueId.id
    ).catch(ApiTabs.createErrorSuppressor(
      ApiTabs.handleMissingTabError // The tab can be closed while waiting.
    ));
  else
    browser.sessions.removeTabValue(
      tab.id,
      Constants.kPERSISTENT_INSERT_AFTER
    ).catch(ApiTabs.createErrorSuppressor(
      ApiTabs.handleMissingTabError // The tab can be closed while waiting.
    ));

  // This code should be removed after legacy data are cleared enough, maybe after Firefox 128 is released.
  browser.sessions.removeTabValue(
    tab.id,
    Constants.kPERSISTENT_INSERT_AFTER_LEGACY
  ).catch(ApiTabs.createErrorSuppressor(
    ApiTabs.handleMissingTabError // The tab can be closed while waiting.
  ));

  const next = tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab;
  if (next)
    browser.sessions.setTabValue(
      tab.id,
      Constants.kPERSISTENT_INSERT_BEFORE,
      next.$TST.uniqueId.id
    ).catch(ApiTabs.createErrorSuppressor(
      ApiTabs.handleMissingTabError // The tab can be closed while waiting.
    ));
  else
    browser.sessions.removeTabValue(
      tab.id,
      Constants.kPERSISTENT_INSERT_BEFORE
    ).catch(ApiTabs.createErrorSuppressor(
      ApiTabs.handleMissingTabError // The tab can be closed while waiting.
    ));
}


export function reserveToUpdateAncestors(tabOrTabs) {
  const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ;
  for (const tab of tabs) {
    if (!TabsStore.ensureLivingItem(tab))
      continue;
    const reserved = reserveToUpdateAncestors.reserved.get(tab.windowId) || {
      timer: null,
      tabs:  new Set()
    };
    if (reserved.timer)
      clearTimeout(reserved.timer);
    reserved.tabs.add(tab);
    reserved.timer = setTimeout(() => {
      reserveToUpdateAncestors.reserved.delete(tab.windowId);
      for (const tab of reserved.tabs) {
        if (!tab.$TST)
          continue;
        updateAncestors(tab);
      }
    }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS);
    reserveToUpdateAncestors.reserved.set(tab.windowId, reserved);
  }
}
reserveToUpdateAncestors.reserved = new Map();

async function updateAncestors(tab) {
  if (!TabsStore.ensureLivingItem(tab))
    return;

  const ancestors = tab.$TST.ancestors.map(ancestor => ancestor.$TST.uniqueId.id);
  log(`updateAncestors: save persistent ancestors for ${tab.id}: `, ancestors);
  browser.sessions.setTabValue(
    tab.id,
    Constants.kPERSISTENT_ANCESTORS,
    ancestors
  ).catch(ApiTabs.createErrorSuppressor(
    ApiTabs.handleMissingTabError // The tab can be closed while waiting.
  ));
}

export function reserveToUpdateChildren(tabOrTabs) {
  const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ;
  for (const tab of tabs) {
    if (!TabsStore.ensureLivingItem(tab))
      continue;
    const reserved = reserveToUpdateChildren.reserved.get(tab.windowId) || {
      timer: null,
      tabs:  new Set()
    };
    if (reserved.timer)
      clearTimeout(reserved.timer);
    reserved.tabs.add(tab);
    reserved.timer = setTimeout(() => {
      reserveToUpdateChildren.reserved.delete(tab.windowId);
      for (const tab of reserved.tabs) {
        if (!tab.$TST)
          continue;
        updateChildren(tab);
      }
    }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS);
    reserveToUpdateChildren.reserved.set(tab.windowId, reserved);
  }
}
reserveToUpdateChildren.reserved = new Map();

async function updateChildren(tab) {
  if (!TabsStore.ensureLivingItem(tab))
    return;

  const children = tab.$TST.children.map(child => child.$TST.uniqueId.id);
  log(`updateChildren: save persistent children for ${tab.id}: `, children);
  browser.sessions.setTabValue(
    tab.id,
    Constants.kPERSISTENT_CHILDREN,
    children
  ).catch(ApiTabs.createErrorSuppressor(
    ApiTabs.handleMissingTabError // The tab can be closed while waiting.
  ));
}

function reserveToUpdateSubtreeCollapsed(tab) {
  if (!mInitialized ||
      !TabsStore.ensureLivingItem(tab))
    return;
  const reserved = reserveToUpdateSubtreeCollapsed.reserved.get(tab.windowId) || {
    timer: null,
    tabs:  new Set()
  };
  if (reserved.timer)
    clearTimeout(reserved.timer);
  reserved.tabs.add(tab);
  reserved.timer = setTimeout(() => {
    reserveToUpdateSubtreeCollapsed.reserved.delete(tab.windowId);
    for (const tab of reserved.tabs) {
      if (!tab.$TST)
        continue;
      updateSubtreeCollapsed(tab);
    }
  }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS);
  reserveToUpdateSubtreeCollapsed.reserved.set(tab.windowId, reserved);
}
reserveToUpdateSubtreeCollapsed.reserved = new Map();

async function updateSubtreeCollapsed(tab) {
  if (!TabsStore.ensureLivingItem(tab))
    return;
  tab.$TST.toggleState(Constants.kTAB_STATE_SUBTREE_COLLAPSED, tab.$TST.subtreeCollapsed, { permanently: true });
}

export async function confirmToCloseTabs(tabs, { windowId, configKey, messageKey, titleKey, minConfirmCount } = {}) {
  if (!windowId)
    windowId = tabs[0].windowId;

  const grantedIds = new Set(configs.grantedRemovingTabIds);
  let count = 0;
  const tabIds = [];
  tabs = tabs.map(tab => Tab.get(tab?.id)).filter(tab => {
    if (tab && !grantedIds.has(tab.id)) {
      count++;
      tabIds.push(tab.id);
      return true;
    }
    return false;
  });
  if (!configKey)
    configKey = 'warnOnCloseTabs';
  const shouldConfirm = configs[configKey];
  const deltaFromLastConfirmation = Date.now() - configs.lastConfirmedToCloseTabs;
  log('confirmToCloseTabs ', { tabIds, count, windowId, configKey, grantedIds, shouldConfirm, deltaFromLastConfirmation, minConfirmCount });
  if (count <= (typeof minConfirmCount == 'number' ? minConfirmCount : 1) ||
      !shouldConfirm ||
      deltaFromLastConfirmation < 500) {
    log('confirmToCloseTabs: skip confirmation and treated as granted');
    return true;
  }

  const win = await browser.windows.get(windowId);
  const listing = configs.warnOnCloseTabsWithListing ?
    Dialog.tabsToHTMLList(tabs, {
      maxHeight: Math.round(win.height * 0.8),
      maxWidth: Math.round(win.width * 0.75)
    }) :
    '';

  const result = await Dialog.show(win, {
    content: `
      <div>${sanitizeForHTMLText(browser.i18n.getMessage(messageKey || 'warnOnCloseTabs_message', [count]))}</div>${listing}
    `.trim(),
    buttons: [
      browser.i18n.getMessage('warnOnCloseTabs_close'),
      browser.i18n.getMessage('warnOnCloseTabs_cancel')
    ],
    checkMessage: browser.i18n.getMessage('warnOnCloseTabs_warnAgain'),
    checked: true,
    modal:   !configs.debug, // for popup
    type:    'common-dialog', // for popup
    url:     ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), // for popup
    title:   browser.i18n.getMessage(titleKey || 'warnOnCloseTabs_title'), // for popup
    onShownInPopup(container) {
      setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document.
        // this need to be done on the next tick, to use the height of
        // the box for calculation of dialog size
        const style = container.querySelector('ul').style;
        style.height = '0px'; // this makes the box shrinkable
        style.maxHeight = 'none';
        style.minHeight = '0px';
      }, 0);
    }
  });

  log('confirmToCloseTabs: result = ', result);
  switch (result.buttonIndex) {
    case 0:
      if (!result.checked)
        configs[configKey] = false;
      configs.grantedRemovingTabIds = Array.from(new Set((configs.grantedRemovingTabIds || []).concat(tabIds)));
      log('confirmToCloseTabs: granted ', configs.grantedRemovingTabIds);
      reserveToClearGrantedRemovingTabs();
      return true;
    default:
      return false;
  }
}
Commands.onTabsClosing.addListener((tabIds, options = {}) => {
  return confirmToCloseTabs(tabIds.map(Tab.get), options);
});

function reserveToClearGrantedRemovingTabs() {
  const lastGranted = configs.grantedRemovingTabIds.join(',');
  setTimeout(() => {
    if (configs.grantedRemovingTabIds.join(',') == lastGranted)
      configs.grantedRemovingTabIds = [];
  }, 1000);
}

Tab.onCreated.addListener((tab, info = {}) => {
  if (!info.duplicated)
    return;
  // Duplicated tab has its own tree structure information inherited
  // from the original tab, but they must be cleared.
  reserveToUpdateAncestors(tab);
  reserveToUpdateChildren(tab);
  reserveToUpdateInsertionPosition([
    tab,
    tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab,
    tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab
  ]);
});

Tab.onUpdated.addListener((tab, changeInfo) => {
  if (!mInitialized)
    return;

  // Loading of "about:(unknown type)" won't report new URL via tabs.onUpdated,
  // so we need to see the complete tab object.
  const status = changeInfo.status || tab?.status;
  const url = changeInfo.url ? changeInfo.url :
    status == 'complete' && tab ? tab.url : '';
  if (tab &&
      Constants.kSHORTHAND_ABOUT_URI.test(url)) {
    const shorthand = RegExp.$1;
    const oldUrl = tab.url;
    wait(100).then(() => { // redirect with delay to avoid infinite loop of recursive redirections.
      if (tab.url != oldUrl)
        return;
      browser.tabs.update(tab.id, {
        url: url.replace(Constants.kSHORTHAND_ABOUT_URI, Constants.kSHORTHAND_URIS[shorthand] || 'about:blank')
      }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError));
      if (shorthand == 'group')
        tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true });
    });
  }

  if (changeInfo.status || changeInfo.url)
    tryStartHandleAccelKeyOnTab(tab);
});

Tab.onShown.addListener(tab => {
  if (!mInitialized)
    return;

  if (configs.fixupTreeOnTabVisibilityChanged) {
    reserveToUpdateAncestors(tab);
    reserveToUpdateChildren(tab);
  }
  reserveToUpdateInsertionPosition([
    tab,
    tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab,
    tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab
  ]);
});

Tab.onMutedStateChanged.addListener((root, toBeMuted) => {
  if (!mInitialized)
    return;

  // Spread muted state of a parent tab to its collapsed descendants
  if (!root.$TST.subtreeCollapsed ||
      // We don't need to spread muted state to descendants of multiselected
      // tabs here, because tabs.update() was called with all multiselected tabs.
      root.$TST.multiselected ||
      // We should not spread muted state to descendants of collapsed tab
      // recursively, because they were already controlled from a visible
      // ancestor.
      root.$TST.collapsed)
    return;

  const tabs = root.$TST.descendants;
  for (const tab of tabs) {
    const playing = tab.$TST.soundPlaying;
    const muted   = tab.$TST.muted;
    log(`tab ${tab.id}: playing=${playing}, muted=${muted}`);
    if (configs.spreadMutedStateOnlyToSoundPlayingTabs &&
        !playing &&
        playing != toBeMuted)
      continue;

    log(` => set muted=${toBeMuted}`);
    browser.tabs.update(tab.id, {
      muted: toBeMuted
    }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError));

    const add = [];
    const remove = [];
    if (toBeMuted) {
      add.push(Constants.kTAB_STATE_MUTED);
      tab.$TST.addState(Constants.kTAB_STATE_MUTED);
    }
    else {
      remove.push(Constants.kTAB_STATE_MUTED);
      tab.$TST.removeState(Constants.kTAB_STATE_MUTED);
    }

    if (tab.audible && !toBeMuted) {
      add.push(Constants.kTAB_STATE_SOUND_PLAYING);
      tab.$TST.addState(Constants.kTAB_STATE_SOUND_PLAYING);
    }
    else {
      remove.push(Constants.kTAB_STATE_SOUND_PLAYING);
      tab.$TST.removeState(Constants.kTAB_STATE_SOUND_PLAYING);
    }

    // tabs.onUpdated is too slow, so users will be confused
    // from still-not-updated tabs (in other words, they tabs
    // are unresponsive for quick-clicks).
    Tab.broadcastState(tab, { add, remove });
  }
});

Tab.onTabInternallyMoved.addListener((tab, info = {}) => {
  reserveToUpdateInsertionPosition([
    tab,
    tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab,
    tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab,
    info.oldPreviousTab,
    info.oldNextTab
  ]);
});

Tab.onMoved.addListener((tab, moveInfo) => {
  if (moveInfo.movedInBulk)
    return;
  reserveToUpdateInsertionPosition([
    tab,
    moveInfo.oldPreviousTab,
    moveInfo.oldNextTab,
    tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab,
    tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab
  ]);
});

Tree.onAttached.addListener(async (tab, attachInfo) => {
  await tab.$TST.opened;

  if (!TabsStore.ensureLivingItem(tab) || // not removed while waiting
      tab.$TST.parent != attachInfo.parent) // not detached while waiting
    return;

  if (attachInfo.newlyAttached)
    reserveToUpdateAncestors([tab].concat(tab.$TST.descendants));
  reserveToUpdateChildren(tab.$TST.parent);
  reserveToUpdateInsertionPosition([
    tab,
    tab.$TST.nextTab,
    tab.$TST.previousTab
  ]);
});

Tree.onDetached.addListener((tab, detachInfo) => {
  reserveToUpdateAncestors([tab].concat(tab.$TST.descendants));
  reserveToUpdateChildren(detachInfo.oldParentTab);
});

Tree.onSubtreeCollapsedStateChanging.addListener((tab, _info) => { reserveToUpdateSubtreeCollapsed(tab); });


const BASE_ICONS = {
  '16': '/resources/16x16.svg',
  '20': '/resources/20x20.svg',
  '24': '/resources/24x24.svg',
  '32': '/resources/32x32.svg',
};
async function updateIconForBrowserTheme(theme) {
  // generate icons with theme specific color
  const toolbarIcons = {};
  const menuIcons    = {};
  const sidebarIcons = {};

  if (!theme) {
    const win = await browser.windows.getLastFocused();
    theme = await browser.theme.getCurrent(win.id);
  }

  log('updateIconForBrowserTheme: ', theme);
  if (theme.colors) {
    const toolbarIconColor = theme.colors.icons || theme.colors.toolbar_text || theme.colors.tab_text || theme.colors.tab_background_text || theme.colors.bookmark_text || theme.colors.textcolor;
    const menuIconColor    = theme.colors.popup_text || toolbarIconColor;
    const sidebarIconColor = theme.colors.sidebar_text || toolbarIconColor;
    log(' => ', { toolbarIconColor, menuIconColor, sidebarIconColor }, theme.colors);
    await Promise.all(Array.from(Object.entries(BASE_ICONS), async ([size, url]) => {
      const response = await fetch(url);
      const body = await response.text();
      const toolbarIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, toolbarIconColor);
      toolbarIcons[size] = `data:image/svg+xml,${escape(toolbarIconSource)}#toolbar-theme`;
      const menuIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, menuIconColor);
      menuIcons[size] = `data:image/svg+xml,${escape(menuIconSource)}#default-theme`;
      const sidebarIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, sidebarIconColor);
      sidebarIcons[size] = `data:image/svg+xml,${escape(sidebarIconSource)}#default-theme`;
    }));
  }
  else {
    for (const [size, url] of Object.entries(BASE_ICONS)) {
      toolbarIcons[size] = `${url}#toolbar`;
      menuIcons[size] = sidebarIcons[size] = `${url}#default`;
    }
  }

  log('updateIconForBrowserTheme: applying icons: ', {
    toolbarIcons,
    menuIcons,
    sidebarIcons,
  });

  await Promise.all([
    ...ContextMenu.getItemIdsWithIcon().map(id => browser.menus.update(id, { icons: menuIcons })),
    browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()),
    browser.action?.setIcon({ path: toolbarIcons }), // Manifest v2
    browser.browserAction?.setIcon({ path: toolbarIcons }), // Manifest v3
    browser.sidebarAction?.setIcon({ path: sidebarIcons }),
  ]);
}

browser.theme.onUpdated.addListener(updateInfo => {
  updateIconForBrowserTheme(updateInfo.theme);
});

mDarkModeMatchMedia.addListener(async _event => {
  updateIconForBrowserTheme();
});



configs.$addObserver(key => {
  switch (key) {
    case 'style':
      updatePanelUrl();
      break;

    case 'debug':
      EventListenerManager.debug = configs.debug;
      break;

    case 'testKey': // for tests/utils.js
      browser.runtime.sendMessage({
        type:  Constants.kCOMMAND_NOTIFY_TEST_KEY_CHANGED,
        value: configs.testKey
      });
      break;
  }
});
