/* ***** BEGIN LICENSE BLOCK ***** 
 * Version: MPL 1.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is the Tree Style Tab.
 *
 * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi.
 * Portions created by the Initial Developer are Copyright (C) 2011-2025
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s): YUKI "Piro" Hiroshi <piro.outsider.reflex@gmail.com>
 *                 wanabe <https://github.com/wanabe>
 *                 Tetsuharu OHZEKI <https://github.com/saneyuki>
 *                 Xidorn Quan <https://github.com/upsuper> (Firefox 40+ support)
 *                 lv7777 (https://github.com/lv7777)
 *
 * ***** END LICENSE BLOCK ******/
'use strict';

import {
  log as internalLogger,
  wait,
  toLines,
  configs
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as Constants from '/common/constants.js';
import { SequenceMatcher } from '/extlib/diff.js';
import * as SidebarConnection from '/common/sidebar-connection.js';
import * as TabsStore from '/common/tabs-store.js';

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

function log(...args) {
  internalLogger('background/tabs-move', ...args);
}
function logApiTabs(...args) {
  internalLogger('common/api-tabs', ...args);
}


// ========================================================
// primitive methods for internal use

export async function moveTabsBefore(tabs, referenceTab, options = {}) {
  log('moveTabsBefore: ', tabs, referenceTab, options);
  if (!tabs.length ||
      !TabsStore.ensureLivingItem(referenceTab))
    return [];

  if (referenceTab.$TST.isAllPlacedBeforeSelf(tabs)) {
    log('moveTabsBefore:no need to move');
    return [];
  }
  return moveTabsInternallyBefore(tabs, referenceTab, options);
}
export async function moveTabBefore(tab, referenceTab, options = {}) {
  return moveTabsBefore([tab], referenceTab, options).then(moved => moved.length > 0);
}

async function moveTabsInternallyBefore(tabs, referenceTab, options = {}) {
  if (!tabs.length ||
      !TabsStore.ensureLivingItem(referenceTab))
    return [];

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

  log('moveTabsInternallyBefore: ', tabs, `${referenceTab.id}(index=${referenceTab.index})`, options);
  if (referenceTab.type == TreeItem.TYPE_GROUP) {
    referenceTab = referenceTab.$TST?.firstMember;
    if (!TabsStore.ensureLivingItem(referenceTab)) {
      log('missing reference tab');
      return [];
    }
    log(`  => reference tab: ${referenceTab.id}(index=${referenceTab.index})`);
  }

  const precedingReferenceTab = referenceTab.$TST.previousTab;
  if (referenceTab.pinned) {
    // unpinned tab cannot be moved before any pinned tab
    tabs = tabs.filter(tab => tab.pinned);
  }
  else if (precedingReferenceTab &&
           !precedingReferenceTab.pinned) {
    // pinned tab cannot be moved after any unpinned tab
    tabs = tabs.filter(tab => !tab.pinned);
  }
  if (!tabs.length)
    return [];

  const movedTabs = [];
  try {
    /*
      Tab elements are moved by tabs.onMoved automatically, but
      the operation is asynchronous. To help synchronous operations
      following to this operation, we need to move tabs immediately.
    */
    const tabGroups = new Set();
    for (const tab of tabs) {
      const oldPreviousTab = tab.$TST.unsafePreviousTab;
      const oldNextTab     = tab.$TST.unsafeNextTab;
      if (oldNextTab?.id == referenceTab.id) // no move case
        continue;
      const fromIndex = tab.index;
      if (referenceTab.index > tab.index)
        tab.index = referenceTab.index - 1;
      else
        tab.index = referenceTab.index;
      tabGroups.add(tab.$TST.nativeTabGroup);
      if (SidebarConnection.isInitialized()) { // only on the background page
        win.internalMovingTabs.set(tab.id, tab.index);
        win.alreadyMovedTabs.set(tab.id, tab.index);
      }
      tab.reindexedBy = `moveTabsInternallyBefore (${tab.index})`;
      Tab.track(tab);
      movedTabs.push(tab);
      Tab.onTabInternallyMoved.dispatch(tab, {
        nextTab: referenceTab,
        oldPreviousTab,
        oldNextTab,
        broadcasted: !!options.broadcasted
      });
      SidebarConnection.sendMessage({
        type:        Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED,
        windowId:    tab.windowId,
        tabId:       tab.id,
        fromIndex,
        toIndex:     tab.index,
        nextTabId:   referenceTab?.id,
        broadcasted: !!options.broadcasted
      });
      if (options.doNotOptimize) {
        win.internalMovingTabs.set(tab.id, tab.index);
        win.alreadyMovedTabs.set(tab.id, tab.index);
        await browser.tabs.move(tab.id, { index: tab.index });
        win.internalMovingTabs.delete(tab.id);
        win.alreadyMovedTabs.delete(tab.id);
      }
    }
    for (const group of tabGroups) {
      group?.$TST.reindex();
    }
    if (movedTabs.length == 0) {
      log(' => actually nothing moved');
    }
    else {
      log(
        'Tab nodes rearranged by moveTabsInternallyBefore:\n',
        (!configs.debug ? '' :
          () => toLines(Array.from(win.getOrderedTabs()),
                        tab => ` - ${tab.index}: ${tab.id}${tabs.includes(tab) ? '[MOVED]' : ''}`))
      );
    }
    if (SidebarConnection.isInitialized()) { // only on the background page
      if (options.delayedMove) { // Wait until opening animation is finished.
        await wait(configs.newTabAnimationDuration);
      }
      if (!options.doNotOptimize) {
        syncToNativeTabs(tabs);
      }
    }
  }
  catch(e) {
    ApiTabs.handleMissingTabError(e);
    log('moveTabsInternallyBefore failed: ', String(e));
  }
  return movedTabs;
}
export async function moveTabInternallyBefore(tab, referenceTab, options = {}) {
  return moveTabsInternallyBefore([tab], referenceTab, options);
}

export async function moveTabsAfter(tabs, referenceTab, options = {}) {
  log('moveTabsAfter: ', tabs, referenceTab, options);
  if (!tabs.length ||
      !TabsStore.ensureLivingItem(referenceTab))
    return [];

  if (referenceTab.$TST.isAllPlacedAfterSelf(tabs)) {
    log('moveTabsAfter:no need to move');
    return [];
  }
  return moveTabsInternallyAfter(tabs, referenceTab, options);
}
export async function moveTabAfter(tab, referenceTab, options = {}) {
  return moveTabsAfter([tab], referenceTab, options).then(moved => moved.length > 0);
}

async function moveTabsInternallyAfter(tabs, referenceTab, options = {}) {
  if (!tabs.length ||
      !TabsStore.ensureLivingItem(referenceTab))
    return [];

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

  log('moveTabsInternallyAfter: ', tabs, `${referenceTab.id}(index=${referenceTab.index})`, options);
  if (referenceTab.type == TreeItem.TYPE_GROUP) {
    if (!referenceTab.collapsed) {
      log('  => move before the first member tab of the reference group');
      return moveTabsInternallyBefore(tabs, referenceTab.$TST?.firstMember, options = {});
    }
    referenceTab = referenceTab.$TST?.lastMember;
    if (!TabsStore.ensureLivingItem(referenceTab)) {
      log('missing reference tab');
      return [];
    }
    log(`  => reference tab: ${referenceTab.id}(index=${referenceTab.index})`);
  }

  const followingReferenceTab = referenceTab.$TST.nextTab;
  if (followingReferenceTab &&
      followingReferenceTab.pinned) {
    // unpinned tab cannot be moved before any pinned tab
    tabs = tabs.filter(tab => tab.pinned);
  }
  else if (!referenceTab.pinned) {
    // pinned tab cannot be moved after any unpinned tab
    tabs = tabs.filter(tab => !tab.pinned);
  }
  if (!tabs.length)
    return [];

  const movedTabs = [];
  try {
    /*
      Tab elements are moved by tabs.onMoved automatically, but
      the operation is asynchronous. To help synchronous operations
      following to this operation, we need to move tabs immediately.
    */
    let nextTab = referenceTab.$TST.unsafeNextTab;
    while (nextTab && tabs.find(tab => tab.id == nextTab.id)) {
      nextTab = nextTab.$TST.unsafeNextTab;
    }
    const tabGroups = new Set();
    for (const tab of tabs) {
      const oldPreviousTab = tab.$TST.unsafePreviousTab;
      const oldNextTab     = tab.$TST.unsafeNextTab;
      if ((!oldNextTab && !nextTab) ||
          (oldNextTab && nextTab && oldNextTab.id == nextTab.id)) // no move case
        continue;
      const fromIndex = tab.index;
      if (nextTab) {
        if (nextTab.index > tab.index)
          tab.index = nextTab.index - 1;
        else
          tab.index = nextTab.index;
      }
      else {
        tab.index = win.tabs.size - 1
      }
      tabGroups.add(tab.$TST.nativeTabGroup);
      if (SidebarConnection.isInitialized()) { // only on the background page
        win.internalMovingTabs.set(tab.id, tab.index);
        win.alreadyMovedTabs.set(tab.id, tab.index);
      }
      tab.reindexedBy = `moveTabsInternallyAfter (${tab.index})`;
      Tab.track(tab);
      movedTabs.push(tab);
      Tab.onTabInternallyMoved.dispatch(tab, {
        nextTab,
        oldPreviousTab,
        oldNextTab,
        broadcasted: !!options.broadcasted
      });
      SidebarConnection.sendMessage({
        type:        Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED,
        windowId:    tab.windowId,
        tabId:       tab.id,
        fromIndex,
        toIndex:     tab.index,
        nextTabId:   nextTab?.id,
        broadcasted: !!options.broadcasted
      });
      if (options.doNotOptimize) {
        win.internalMovingTabs.set(tab.id, tab.index);
        win.alreadyMovedTabs.set(tab.id, tab.index);
        await browser.tabs.move(tab.id, { index: tab.index });
        win.internalMovingTabs.delete(tab.id);
        win.alreadyMovedTabs.delete(tab.id);
      }
    }
    for (const group of tabGroups) {
      group?.$TST.reindex();
    }
    if (movedTabs.length == 0) {
      log(' => actually nothing moved');
    }
    else {
      log(
        'Tab nodes rearranged by moveTabsInternallyAfter:\n',
        (!configs.debug ? '' :
          () => toLines(Array.from(win.getOrderedTabs()),
                        tab => ` - ${tab.index}: ${tab.id}${tabs.includes(tab) ? '[MOVED]' : ''}`))
      );
    }
    if (SidebarConnection.isInitialized()) { // only on the background page
      if (options.delayedMove) { // Wait until opening animation is finished.
        await wait(configs.newTabAnimationDuration);
      }
      if (!options.doNotOptimize) {
        syncToNativeTabs(tabs);
      }
    }
  }
  catch(e) {
    ApiTabs.handleMissingTabError(e);
    log('moveTabsInternallyAfter failed: ', String(e));
  }
  return movedTabs;
}
export async function moveTabInternallyAfter(tab, referenceTab, options = {}) {
  return moveTabsInternallyAfter([tab], referenceTab, options);
}


// ========================================================
// Synchronize order of tab elements to browser's tabs

const mPreviousSync     = new Map();
const mDelayedSync      = new Map();
const mDelayedSyncTimer = new Map();

export async function waitUntilSynchronized(windowId) {
  const previous = mPreviousSync.get(windowId);
  if (previous)
    return previous.then(() => waitUntilSynchronized(windowId));
  return Promise.resolve(mDelayedSync.get(windowId)).then(() => {
    const previous = mPreviousSync.get(windowId);
    if (previous)
      return waitUntilSynchronized(windowId);
  });
}

function syncToNativeTabs(tabs) {
  const windowId = tabs[0].windowId;
  //log(`syncToNativeTabs(${windowId})`);
  if (mDelayedSyncTimer.has(windowId))
    clearTimeout(mDelayedSyncTimer.get(windowId));
  const delayedSync = new Promise((resolve, _reject) => {
    mDelayedSyncTimer.set(windowId, setTimeout(() => {
      mDelayedSync.delete(windowId);
      let previousSync = mPreviousSync.get(windowId);
      if (previousSync)
        previousSync = previousSync.then(() => syncToNativeTabsInternal(windowId));
      else
        previousSync = syncToNativeTabsInternal(windowId);
      previousSync = previousSync.then(resolve);
      mPreviousSync.set(windowId, previousSync);
    }, 250));
  }).then(() => {
    mPreviousSync.delete(windowId);
  });
  mDelayedSync.set(windowId, delayedSync);
  return delayedSync;
}
async function syncToNativeTabsInternal(windowId) {
  mDelayedSyncTimer.delete(windowId);

  if (Tab.needToWaitTracked(windowId))
    await Tab.waitUntilTrackedAll(windowId);
  if (Tab.needToWaitMoved(windowId))
    await Tab.waitUntilMovedAll(windowId);

  const win = TabsStore.windows.get(windowId);
  if (!win) // already destroyed
    return;

  // Tabs may be removed while waiting.
  const internalOrder   = TabsStore.windows.get(windowId).order;
  const nativeTabsOrder = (await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler())).map(tab => tab.id);
  log(`syncToNativeTabs(${windowId}): rearrange `, { internalOrder:internalOrder.join(','), nativeTabsOrder:nativeTabsOrder.join(',') });

  log(`syncToNativeTabs(${windowId}): step1, internalOrder => nativeTabsOrder`);
  let tabIdsForUpdatedIndices = Array.from(nativeTabsOrder);

  const moveOperations = (new SequenceMatcher(nativeTabsOrder, internalOrder)).operations();
  const movedTabs = new Set();
  for (const operation of moveOperations) {
    const [tag, fromStart, fromEnd, toStart, toEnd] = operation;
    log(`syncToNativeTabs(${windowId}): operation `, { tag, fromStart, fromEnd, toStart, toEnd });
    switch (tag) {
      case 'equal':
      case 'delete':
        break;

      case 'insert':
      case 'replace':
        let moveTabIds = internalOrder.slice(toStart, toEnd);
        const referenceId = nativeTabsOrder[fromStart] || null;
        let toIndex = -1;
        let fromIndices = moveTabIds.map(id => tabIdsForUpdatedIndices.indexOf(id));
        if (referenceId) {
          toIndex = tabIdsForUpdatedIndices.indexOf(referenceId);
        }
        if (toIndex < 0)
          toIndex = internalOrder.length;
        // ignore already removed tabs!
        moveTabIds = moveTabIds.filter((id, index) => fromIndices[index] > -1);
        if (moveTabIds.length == 0)
          continue;
        fromIndices = fromIndices.filter(index => index > -1);
        const fromIndex = fromIndices[0];
        if (fromIndex < toIndex)
          toIndex--;
        log(`syncToNativeTabs(${windowId}): step1, move ${moveTabIds.join(',')} before ${referenceId} / from = ${fromIndex}, to = ${toIndex}`);
        for (const movedId of moveTabIds) {
          win.internalMovingTabs.set(movedId, -1);
          win.alreadyMovedTabs.set(movedId, -1);
          movedTabs.add(movedId);
        }
        logApiTabs(`tabs-move:syncToNativeTabs(${windowId}): step1, browser.tabs.move() `, moveTabIds, {
          windowId,
          index: toIndex
        });
        let reallyMovedTabIds = new Set();
        try {
          const reallyMovedTabs = await browser.tabs.move(moveTabIds, {
            windowId,
            index: toIndex
          }).catch(ApiTabs.createErrorHandler(e => {
            log(`syncToNativeTabs(${windowId}): step1, failed to move: `, String(e), e.stack);
            throw e;
          }));
          reallyMovedTabIds = new Set(reallyMovedTabs.map(tab => tab.id));
        }
        catch(error) {
          console.error(error);
        }
        for (const id of moveTabIds) {
          if (reallyMovedTabIds.has(id))
            continue;
          log(`syncToNativeTabs(${windowId}): failed to move tab ${id}: maybe unplacable position (regular tabs in pinned tabs/pinned tabs in regular tabs), or any other reason`);
          win.internalMovingTabs.delete(id);
          win.alreadyMovedTabs.delete(id);
        }
        tabIdsForUpdatedIndices = tabIdsForUpdatedIndices.filter(id => !moveTabIds.includes(id));
        tabIdsForUpdatedIndices.splice(toIndex, 0, ...moveTabIds);
        break;
    }
  }
  log(`syncToNativeTabs(${windowId}): step1, rearrange completed.`);

  if (movedTabs.size > 0) {
    // tabs.onMoved produced by this operation can break the order of tabs
    // in the sidebar, so we need to synchronize complete order of tabs after
    // all.
    SidebarConnection.sendMessage({
      type: Constants.kCOMMAND_SYNC_TABS_ORDER,
      windowId
    });

    // Multiple times asynchronous tab move is unstable, so we retry again
    // for safety until all tabs are completely synchronized.
    syncToNativeTabs([{ windowId }]);
  }
}
