/*
# 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 {
  log as internalLogger,
  dumpTab,
  configs,
  sanitizeForHTMLText,
  compareAsNumber,
  isFirefoxViewTab,
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as Bookmark from '/common/bookmark.js';
import * as Constants from '/common/constants.js';
import * as Dialog from '/common/dialog.js';
import * as Permissions from '/common/permissions.js';
import * as TabsStore from '/common/tabs-store.js';
import * as TSTAPI from '/common/tst-api.js';

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

import * as TabsGroup from './tabs-group.js';
import * as TabsOpen from './tabs-open.js';
import * as Tree from './tree.js';

function log(...args) {
  internalLogger('background/handle-tab-bunches', ...args);
}

// ====================================================================
// Detection of a bunch of tabs opened at same time.
// Firefox's WebExtensions API doesn't provide ability to know which tabs
// are opened together by a single trigger. Thus TST tries to detect such
// "tab bunches" based on their opened timing.
// ====================================================================

Tab.onBeforeCreate.addListener(async (tab, info) => {
  const win = TabsStore.windows.get(tab.windowId);
  if (!win)
    return;

  const openerId  = tab.openerTabId;
  const openerTab = openerId && (await browser.tabs.get(openerId).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)));
  if ((openerTab &&
       (openerTab.pinned || isFirefoxViewTab(openerTab)) &&
       openerTab.windowId == tab.windowId) ||
      (!openerTab &&
       !info.maybeOrphan)) {
    if (win.preventToDetectTabBunchesUntil > Date.now()) {
      win.preventToDetectTabBunchesUntil += configs.tabBunchesDetectionTimeout;
    }
    else {
      win.openedNewTabs.set(tab.id, {
        id:       tab.id,
        windowId: tab.windowId,
        indexOnCreated: tab.$indexOnCreated,
        openerId: openerTab?.id,
        openerIsPinned: openerTab?.pinned,
        openerIsFirefoxView: isFirefoxViewTab(openerTab),
        maybeFromBookmark: tab.$TST.maybeFromBookmark,
        shouldNotGrouped: TSTAPI.isGroupingBlocked(),
      });
    }
  }
  if (win.delayedTabBunchesDetection)
    clearTimeout(win.delayedTabBunchesDetection);
  win.delayedTabBunchesDetection = setTimeout(
    tryDetectTabBunches,
    configs.tabBunchesDetectionTimeout,
    win
  );
});

const mPossibleTabBunchesToBeGrouped = [];
const mPossibleTabBunchesFromBookmarks = [];

async function tryDetectTabBunches(win) {
  if (Tab.needToWaitTracked(win.id))
    await Tab.waitUntilTrackedAll(win.id);
  if (Tab.needToWaitMoved(win.id))
    await Tab.waitUntilMovedAll(win.id);

  let tabReferences = Array.from(win.openedNewTabs.values());
  log('tryDetectTabBunches for ', tabReferences);

  win.openedNewTabs.clear();

  tabReferences = tabReferences.filter(tabReference => {
    if (!tabReference.id)
      return false;
    const tab = Tab.get(tabReference.id);
    if (!tab)
      return false;
    const uniqueId = tab?.$TST?.uniqueId;
    return !uniqueId || (!uniqueId.duplicated && !uniqueId.restored);
  });
  if (tabReferences.length == 0) {
    log(' => there is no possible bunches of tabs.');
    return;
  }

  if (tabReferences.length > 1) {
    await Promise.all(tabReferences.map(tabReference => {
      const tab = Tab.get(tabReference.id);
      tab.$TST.temporaryMetadata.set('openedWithOthers', true);
      // We need to wait until all tabs are handlede completely.
      // Otherwise `tab.$TST.needToBeGroupedSiblings` may contain unrelated tabs
      // (tabs opened from any other parent tab) unexpectedly.
      return tab.$TST.opened;
    }));
  }

  if (areTabsFromOtherDeviceWithInsertAfterCurrent(tabReferences) &&
      configs.fixupOrderOfTabsFromOtherDevice) {
    const ids   = tabReferences.map(tabReference => tabReference.id);
    const index = tabReferences.map(tabReference => Tab.get(tabReference.id).index).sort(compareAsNumber)[0];
    log(' => gather tabs from other device at ', ids, index);
    await browser.tabs.move(ids, { index });
  }

  if (configs.autoGroupNewTabsFromPinned ||
      configs.autoGroupNewTabsFromFirefoxView ||
      configs.autoGroupNewTabsFromOthers) {
    mPossibleTabBunchesToBeGrouped.push(tabReferences);
    tryGroupTabBunches();
  }
  if (configs.autoGroupNewTabsFromBookmarks ||
      configs.restoreTreeForTabsFromBookmarks) {
    mPossibleTabBunchesFromBookmarks.push(tabReferences);
    tryHandlTabBunchesFromBookmarks();
  }
}

async function tryGroupTabBunches() {
  if (tryGroupTabBunches.running)
    return;

  const tabReferences = mPossibleTabBunchesToBeGrouped.shift();
  if (!tabReferences)
    return;

  log('tryGroupTabBunches for ', tabReferences);
  tryGroupTabBunches.running = true;
  try {
    const fromPinned    = [];
    const fromOthers    = [];
    // extract only pure new tabs
    for (const tabReference of tabReferences) {
      if (tabReference.shouldNotGrouped)
        continue;
      const tab = Tab.get(tabReference.id);
      if (!tab)
        continue;
      if (tabReference.openerTabId)
        tab.openerTabId = parseInt(tabReference.openerTabId); // restore the opener information
      const uniqueId = tab.$TST.uniqueId;
      if (tab.$TST.isGroupTab || uniqueId.duplicated || uniqueId.restored)
        continue;
      if (tabReference.openerIsPinned ||
          tabReference.openerIsFirefoxView) {
        // We should check the "autoGroupNewTabsFromPinned" config here,
        // because to-be-grouped tabs should be ignored by the handler for
        // "autoAttachSameSiteOrphan" behavior.
        if ((tab.$TST.hasPinnedOpener &&
             configs.autoGroupNewTabsFromPinned) ||
            (tab.$TST.hasFirefoxViewOpener &&
             configs.autoGroupNewTabsFromFirefoxView))
          fromPinned.push(tab);
      }
      else {
        fromOthers.push(tab);
      }
    }
    log(' => ', { fromPinned, fromOthers });

    if (fromPinned.length > 0 &&
        (configs.autoGroupNewTabsFromPinned ||
         configs.autoGroupNewTabsFromFirefoxView)) {
      const newRootTabs = Tab.collectRootTabs(TreeItem.sort(fromPinned));
      if (newRootTabs.length > 0) {
        await tryGroupTabBunchesFromPinnedOpener(newRootTabs);
      }
    }

    // We can assume that new tabs from a bookmark folder and from other
    // sources won't be mixed.
    const openedFromBookmarkFolder = fromOthers.length > 0 && await detectBookmarkFolderFromTabs(fromOthers, tabReferences.length);
    log(' => tryGroupTabBunches:openedFromBookmarkFolder: ', !!openedFromBookmarkFolder);
    const newRootTabs = Tab.collectRootTabs(TreeItem.sort(openedFromBookmarkFolder ? openedFromBookmarkFolder.tabs : fromOthers));
    log(' newRootTabs: ', newRootTabs);
    if (newRootTabs.length > 1 &&
        !openedFromBookmarkFolder && // we should ignore tabs from bookmark folder: they should be handled by tryHandlTabBunchesFromBookmarks
        configs.autoGroupNewTabsFromOthers) {
      const granted = await confirmToAutoGroupNewTabsFromOthers(fromOthers);
      if (granted)
        await TabsGroup.groupTabs(newRootTabs, {
          ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForNewTabsFromOthers),
          broadcast: true
        });
    }
  }
  catch(error) {
    log('Error on tryGroupTabBunches: ', String(error), error.stack);
  }
  finally {
    tryGroupTabBunches.running = false;
    if (mPossibleTabBunchesToBeGrouped.length > 0)
      tryGroupTabBunches();
  }
}

async function confirmToAutoGroupNewTabsFromOthers(tabs) {
  if (tabs.length <= 1 ||
      !configs.warnOnAutoGroupNewTabs)
    return true;

  const windowId = tabs[0].windowId;
  const win = await browser.windows.get(windowId);

  const listing = configs.warnOnAutoGroupNewTabsWithListing ?
    Dialog.tabsToHTMLList(tabs, {
      maxRows:   configs.warnOnAutoGroupNewTabsWithListingMaxRows,
      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('warnOnAutoGroupNewTabs_message', [tabs.length]))}</div>${listing}
    `.trim(),
    buttons: [
      browser.i18n.getMessage('warnOnAutoGroupNewTabs_close'),
      browser.i18n.getMessage('warnOnAutoGroupNewTabs_cancel')
    ],
    checkMessage: browser.i18n.getMessage('warnOnAutoGroupNewTabs_warnAgain'),
    checked: true,
    modal: true, // for popup
    type:  'common-dialog', // for popup
    url:   ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), // for popup
    title: browser.i18n.getMessage('warnOnAutoGroupNewTabs_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);
    }
  });

  switch (result.buttonIndex) {
    case 0:
      if (!result.checked)
        configs.warnOnAutoGroupNewTabs = false;
      return true;
    case 1:
      if (!result.checked) {
        configs.warnOnAutoGroupNewTabs = false;
        configs.autoGroupNewTabsFromOthers = false;
      }
    default:
      return false;
  }
}

async function tryGroupTabBunchesFromPinnedOpener(rootTabs) {
  log(`tryGroupTabBunchesFromPinnedOpener: ${rootTabs.length} root tabs are opened from pinned tabs`);

  // First, collect pinned opener tabs.
  let pinnedOpeners = [];
  const childrenOfPinnedTabs = {};
  for (const tab of rootTabs) {
    const opener = tab.$TST.openerTab;
    if (!pinnedOpeners.includes(opener))
      pinnedOpeners.push(opener);
  }
  log('pinnedOpeners ', () => pinnedOpeners.map(dumpTab));

  // Second, collect tabs opened from pinned openers including existing tabs
  // (which were left ungrouped in previous process).
  const openerOf = {};
  const allRootTabs = await Tab.getRootTabs(rootTabs[0].windowId)
  for (const tab of allRootTabs) {
    if (tab.$TST.getAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER))
      continue;
    if (rootTabs.includes(tab)) { // newly opened tab
      const opener = tab.$TST.openerTab;
      if (!opener)
        continue;
      openerOf[tab.id] = opener;
      const tabs = childrenOfPinnedTabs[opener.id] || [];
      childrenOfPinnedTabs[opener.id] = tabs.concat([tab]);
      continue;
    }
    const opener = Tab.getByUniqueId(tab.$TST.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID));
    if (!opener ||
        !(opener.pinned || isFirefoxViewTab(opener)) ||
        opener.windowId != tab.windowId)
      continue;
    // existing and not yet grouped tab
    if (!pinnedOpeners.includes(opener))
      pinnedOpeners.push(opener);
    openerOf[tab.id] = opener;
    const tabs = childrenOfPinnedTabs[opener.id] || [];
    childrenOfPinnedTabs[opener.id] = tabs.concat([tab]);
  }

  // Ignore pinned openeres which has no child tab to be grouped.
  pinnedOpeners = pinnedOpeners.filter(opener => {
    return childrenOfPinnedTabs[opener.id].length > 1 || Tab.getGroupTabForOpener(opener);
  });
  log(' => ', () => pinnedOpeners.map(dumpTab));

  // Move newly opened tabs to expected position before grouping!
  // Note that we should refer "insertNewChildAt" instead of "insertNewTabFromPinnedTabAt" / "insertNewTabFromFirefoxViewAt"
  // because these children are going to be controlled in a sub tree.
  for (const tab of rootTabs.slice(0).sort((a, b) => a.id - b.id)/* process them in the order they were opened */) {
    const opener   = openerOf[tab.id];
    const siblings = tab.$TST.needToBeGroupedSiblings;
    if (!pinnedOpeners.includes(opener) ||
        Tab.getGroupTabForOpener(opener) ||
        siblings.length == 0 ||
        tab.$TST.temporaryMetadata.has('alreadyMovedAsOpenedFromPinnedOpener'))
      continue;
    let refTabs = {};
    try {
      refTabs = Tree.getReferenceTabsForNewChild(tab, null, {
        lastRelatedTab: opener.$TST.previousLastRelatedTab,
        parent:         siblings[0],
        children:       siblings,
        descendants:    siblings.map(sibling => [sibling, ...sibling.$TST.descendants]).flat()
      });
    }
    catch(_error) {
      // insertChildAt == "no control" case
    }
    if (refTabs.insertAfter) {
      await Tree.moveTabSubtreeAfter(
        tab,
        refTabs.insertAfter,
        { broadcast: true }
      );
      log(`newly opened child ${tab.id} has been moved after ${refTabs.insertAfter?.id}`);
    }
    else if (refTabs.insertBefore) {
      await Tree.moveTabSubtreeBefore(
        tab,
        refTabs.insertBefore,
        { broadcast: true }
      );
      log(`newly opened child ${tab.id} has been moved before ${refTabs.insertBefore?.id}`);
    }
    else {
      continue;
    }
    tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true);
  }

  // Finally, try to group opened tabs.
  const newGroupTabs = new Map();
  for (const opener of pinnedOpeners) {
    const children = childrenOfPinnedTabs[opener.id].sort((a, b) => a.index - b.index);
    let parent = Tab.getGroupTabForOpener(opener);
    if (parent) {
      for (const child of children) {
        TabsStore.removeToBeGroupedTab(child);
      }
      continue;
    }
    log(`trying to group children of ${dumpTab(opener)}: `, () => children.map(dumpTab));
    const uri = TabsGroup.makeGroupTabURI({
      title:       browser.i18n.getMessage('groupTab_fromPinnedTab_label', opener.title),
      openerTabId: opener.$TST.uniqueId.id,
      ...TabsGroup.temporaryStateParams(isFirefoxViewTab(opener) ? configs.groupTabTemporaryStateForChildrenOfFirefoxView : configs.groupTabTemporaryStateForChildrenOfPinned)
    });
    parent = await TabsOpen.openURIInTab(uri, {
      windowId:     opener.windowId,
      insertBefore: children[0],
      cookieStoreId: opener.cookieStoreId,
      inBackground: true
    });
    log('opened group tab: ', dumpTab(parent));
    newGroupTabs.set(opener, true);
    for (const child of children) {
      // Prevent the tab to be grouped again after it is ungrouped manually.
      child.$TST.setAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, true);
      TabsStore.removeToBeGroupedTab(child);
      await Tree.attachTabTo(child, parent, {
        forceExpand: true, // this is required to avoid the group tab itself is active from active tab in collapsed tree
        dontMove:    true,
        broadcast:   true
      });
    }
    if (opener.active)
      parent.$TST.addState(Constants.kTAB_STATE_BUNDLED_ACTIVE);
  }
  return true;
}

async function tryHandlTabBunchesFromBookmarks() {
  if (tryHandlTabBunchesFromBookmarks.running)
    return;

  const tabReferences = mPossibleTabBunchesFromBookmarks.shift();
  if (!tabReferences)
    return;

  log('tryHandlTabBunchesFromBookmarks for ', tabReferences);
  tryHandlTabBunchesFromBookmarks.running = true;
  try {
    const tabs = [];
    for (const tabReference of tabReferences) {
      const tab = Tab.get(tabReference.id);
      if (!tab)
        continue;
      if (tabReference.openerTabId)
        tab.openerTabId = parseInt(tabReference.openerTabId); // restore the opener information
      const uniqueId = tab.$TST.uniqueId;
      if (uniqueId.duplicated || uniqueId.restored)
        continue;
      tabs.push(tab);
    }
    log(' => ', { tabs });

    // We can assume that new tabs from a bookmark folder and from other
    // sources won't be mixed.
    const openedFromBookmarkFolder = tabs.length > 0 && await detectBookmarkFolderFromTabs(tabs, tabReferences.length);
    log(' => tryHandlTabBunchesFromBookmarks:openedFromBookmarkFolder: ', openedFromBookmarkFolder);
    if (openedFromBookmarkFolder) {
      if (configs.restoreTreeForTabsFromBookmarks) {
        log(' ==> trying to restore tree structure from bookmark information');
        const structure = await Bookmark.getTreeStructureFromBookmarkFolder(openedFromBookmarkFolder.folder);
        log(' ==> structure:', structure);
        if (structure.length == openedFromBookmarkFolder.tabs.length) {
          log(' ===> apply');
          await Tree.applyTreeStructureToTabs(openedFromBookmarkFolder.tabs, structure);
        }
      }

      const newRootTabs = Tab.collectRootTabs(TreeItem.sort(openedFromBookmarkFolder.tabs));
      if (newRootTabs.length > 1 &&
          configs.autoGroupNewTabsFromBookmarks &&
          tabReferences.every(tabReference => !tabReference.shouldNotGrouped)) {
        log(' => tryHandlTabBunchesFromBookmarks:group');
        await TabsGroup.groupTabs(openedFromBookmarkFolder.tabs, {
          ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForNewTabsFromBookmarks),
          broadcast: true
        });
      }
    }
  }
  catch(error) {
    log('Error on tryHandlTabBunchesFromBookmarks: ', String(error), error.stack);
  }
  finally {
    tryHandlTabBunchesFromBookmarks.running = false;
    if (mPossibleTabBunchesFromBookmarks.length > 0)
      tryHandlTabBunchesFromBookmarks();
  }
}

async function detectBookmarkFolderFromTabs(tabs, allNewTabsCount = tabs.length) {
  log('detectBookmarkFolderFromTabs: ', tabs, allNewTabsCount);
  return new Promise((resolve, _reject) => {
    const maybeFromBookmarks = [];
    let restCount = tabs.length;
    for (const tab of tabs) {
      tab.$TST.promisedPossibleOpenerBookmarks.then(async bookmarks => {
        log(` bookmarks from tab ${tab.id}: `, bookmarks);
        restCount--;
        if (bookmarks.length > 0)
          maybeFromBookmarks.push(tab);
        const folder = await tryDetectMostTabsContainedBookmarkFolder(maybeFromBookmarks, allNewTabsCount);
        if (folder) {
          log('detectBookmarkFolderFromTabs: found folder => ', { folder, tabs });
          resolve({
            folder,
            tabs: maybeFromBookmarks,
          });
        }
        else if (restCount == 0) {
          resolve(null);
        }
      });
    }
  });
}

async function tryDetectMostTabsContainedBookmarkFolder(bookmarkedTabs, allNewTabsCount = bookmarkedTabs.length) {
  log('tryDetectMostTabsContainedBookmarkFolder ', { bookmarkedTabs, allNewTabsCount });
  const parentIds = bookmarkedTabs.map(tab => tab.$TST.possibleOpenerBookmarks.map(bookmark => bookmark.parentId)).flat();
  const counts    = [];
  const countById = {};
  for (const id of parentIds) {
    if (!(id in countById))
      counts.push(countById[id] = { id, count: 0 });
    countById[id].count++;
  }
  log(' counts: ', counts);
  if (counts.length == 0)
    return null;

  const greatestCountParent = counts.sort((a, b) => b.count - a.count)[0];
  const minCount = allNewTabsCount * configs.tabsFromSameFolderMinThresholdPercentage / 100;
  log(' => ', { greatestCountParent, minCount });
  if (greatestCountParent.count <= minCount)
    return null;

  const items = await browser.bookmarks.get(greatestCountParent.id);
  return Array.isArray(items) ? items[0] : items;
}


// Detect tabs sent from other device with `browser.tabs.insertAfterCurrent`=true based on their index
// (Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1596787 )
// See also: https://github.com/piroor/treestyletab/issues/2419
function areTabsFromOtherDeviceWithInsertAfterCurrent(tabReferences) {
  if (tabReferences.length == 0)
    return false;

  const activeTab = Tab.getActiveTab(tabReferences[0].windowId);
  if (!activeTab)
    return false;

  const activeIndex        = activeTab.index;
  const followingTabsCount = Tab.getTabs(activeTab.windowId).filter(tab => tab.index > activeIndex).length;

  const createdCount    = tabReferences.length;
  const expectedIndices = [activeIndex + 1];
  const actualIndices   = tabReferences.map(tabReference => tabReference.indexOnCreated);

  const overTabsCount = Math.max(0, createdCount - followingTabsCount);
  const shouldCountDown = Math.min(createdCount - 1, createdCount - Math.floor(overTabsCount / 2));
  const shouldCountUp = createdCount - shouldCountDown - 1;
  for (let i = 0; i < shouldCountUp; i++) {
    expectedIndices.push(activeIndex + 2 + i + (createdCount - overTabsCount));
  }
  for (let i = shouldCountDown - 1; i > -1; i--) {
    expectedIndices.push(activeIndex + 1 + i);
  }
  const received = actualIndices.join(',') == expectedIndices.join(',');
  log('areTabsFromOtherDeviceWithInsertAfterCurrent:', received, { overTabsCount, shouldCountUp, shouldCountDown, size: expectedIndices.length, actualIndices, expectedIndices });
  return received;
}
