/*
# 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 { SequenceMatcher } from '/extlib/diff.js';

import {
  log as internalLogger,
  dumpTab,
  wait,
  mapAndFilter,
  configs
} from '/common/common.js';
import * as ApiTabs from '/common/api-tabs.js';
import * as CacheStorage from '/common/cache-storage.js';
import * as Constants from '/common/constants.js';
import * as TabsInternalOperation from '/common/tabs-internal-operation.js';
import * as TabsStore from '/common/tabs-store.js';
import * as TabsUpdate from '/common/tabs-update.js';
import * as UniqueId from '/common/unique-id.js';

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

import * as Tree from './tree.js';

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

const kCONTENTS_VERSION = 5;

let mActivated = false;
const mCaches = {};

export function activate() {
  mActivated = true;
  configs.$addObserver(onConfigChange);

  if (!configs.persistCachedTree) {
    // clear obsolete cache
    browser.windows.getAll().then(windows => {
      for (const win of windows) {
        browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_TABS).catch(ApiTabs.createErrorSuppressor());
        browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY).catch(ApiTabs.createErrorSuppressor());
      }
    });
  }
}


// ===================================================================
// restoring tabs from cache
// ===================================================================

export async function restoreWindowFromEffectiveWindowCache(windowId, options = {}) {
  MetricsData.add('restoreWindowFromEffectiveWindowCache: start');
  log(`restoreWindowFromEffectiveWindowCache for ${windowId} start`);
  const owner = options.owner || getWindowCacheOwner(windowId);
  if (!owner) {
    log(`restoreWindowFromEffectiveWindowCache for ${windowId} fail: no owner`);
    return false;
  }
  cancelReservedCacheTree(windowId); // prevent to break cache before loading
  const tabs = options.tabs || await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
  if (configs.debug)
    log(`restoreWindowFromEffectiveWindowCache for ${windowId} tabs: `, () => tabs.map(dumpTab));
  const actualSignature = getWindowSignature(tabs);
  let cache = options.caches?.get(`window-${owner.windowId}`) || await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: window cache', getWindowCache(owner, Constants.kWINDOW_STATE_CACHED_TABS));
  if (!cache) {
    log(`restoreWindowFromEffectiveWindowCache for ${windowId} fail: no cache`);
    return false;
  }
  const promisedPermanentStates = Promise.all(tabs.map(tab => Tab.get(tab.id).$TST.getPermanentStates())); // don't await at here for better performance
  MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: start');
  let cachedSignature = cache?.signature;
  log(`restoreWindowFromEffectiveWindowCache for ${windowId}: got from the owner `, {
    owner, cachedSignature, cache
  });
  const signatureGeneratedFromCache = getWindowSignature(cache.tabs).join('\n');
  if (cache &&
      cache.tabs &&
      cachedSignature &&
      cachedSignature.join('\n') != signatureGeneratedFromCache) {
    log(`restoreWindowFromEffectiveWindowCache for ${windowId}: cache is broken.`, {
      cachedSignature: cachedSignature.join('\n'),
      signatureGeneratedFromCache
    });
    cache = cachedSignature = null;
    TabsInternalOperation.clearCache(owner);
    MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: signature failed.');
  }
  else {
    MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: signature passed.');
  }
  if (options.ignorePinnedTabs &&
      cache &&
      cache.tabs &&
      cachedSignature) {
    cache.tabs      = trimTabsCache(cache.tabs, cache.pinnedTabsCount);
    cachedSignature = trimSignature(cachedSignature, cache.pinnedTabsCount);
  }
  MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: matching actual signature of got cache');
  const signatureMatchResult = matcheSignatures({
    actual: actualSignature,
    cached: cachedSignature
  });
  log(`restoreWindowFromEffectiveWindowCache for ${windowId}: verify cache`, {
    cache, actualSignature, cachedSignature,
    ...signatureMatchResult,
  });
  if (!cache ||
      cache.version != kCONTENTS_VERSION ||
      !signatureMatchResult.matched) {
    log(`restoreWindowFromEffectiveWindowCache for ${windowId}: no effective cache`);
    TabsInternalOperation.clearCache(owner);
    MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: actual signature failed.');
    return false;
  }
  MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: actual signature passed.');
  cache.offset = signatureMatchResult.offset;

  log(`restoreWindowFromEffectiveWindowCache for ${windowId}: restore from cache`);

  const permanentStates = await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: permanentStatus', promisedPermanentStates); // await at here for better performance
  const restored = await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: restoreTabsFromCache', restoreTabsFromCache(windowId, { cache, tabs, permanentStates }));
  if (restored) {
    MetricsData.add(`restoreWindowFromEffectiveWindowCache: window ${windowId} succeeded`);
    // Now we reload the sidebar if it is opened, because it is the easiest way
    // to synchronize state of tabs completely.
    log('reload sidebar for a tree restored from cache');
    browser.runtime.sendMessage({
      type: Constants.kCOMMAND_RELOAD,
      windowId,
    }).catch(ApiTabs.createErrorSuppressor());
  }
  else {
    MetricsData.add(`restoreWindowFromEffectiveWindowCache: window ${windowId} failed`);
  }

  log(`restoreWindowFromEffectiveWindowCache for ${windowId}: restored = ${restored}`);
  return restored;
}

function getWindowSignature(tabs) {
  return tabs.map(tab => `${tab.cookieStoreId},${tab.incognito},${tab.pinned},${tab.url}`);
}

function trimSignature(signature, ignoreCount) {
  if (!ignoreCount || ignoreCount < 0)
    return signature;
  return signature.slice(ignoreCount);
}

function trimTabsCache(cache, ignoreCount) {
  if (!ignoreCount || ignoreCount < 0)
    return cache;
  return cache.slice(ignoreCount);
}

function matcheSignatures(signatures) {
  const operations = (new SequenceMatcher(signatures.cached, signatures.actual)).operations();
  log('matcheSignatures: operations ', operations);
  let matched = false;
  let offset  = 0;
  for (const operation of operations) {
    const [tag, fromStart, fromEnd, toStart, toEnd] = operation;
    if (tag == 'equal' &&
        fromEnd - fromStart == signatures.cached.length) {
      matched = true;
      break;
    }
    offset += toEnd - toStart;
  }
  log('matcheSignatures: ', { matched, offset });
  return { matched, offset };
}


async function restoreTabsFromCache(windowId, params = {}) {
  if (!params.cache ||
      params.cache.version != kCONTENTS_VERSION)
    return false;

  return (await restoreTabsFromCacheInternal({
    windowId,
    tabs:         params.tabs,
    permanentStates: params.permanentStates,
    offset:       params.cache.offset || 0,
    cache:        params.cache.tabs
  })).length > 0;
}

async function restoreTabsFromCacheInternal(params) {
  MetricsData.add('restoreTabsFromCacheInternal: start');
  log(`restoreTabsFromCacheInternal: restore tabs for ${params.windowId} from cache`);
  const offset = params.offset || 0;
  const win    = TabsStore.windows.get(params.windowId);
  const tabs   = params.tabs.slice(offset).map(tab => Tab.get(tab.id));
  if (offset > 0 &&
      tabs.length <= offset) {
    log('restoreTabsFromCacheInternal: missing window');
    return [];
  }
  log(`restoreTabsFromCacheInternal: there is ${win.tabs.size} tabs`);
  if (params.cache.length != tabs.length) {
    log('restoreTabsFromCacheInternal: Mismatched number of restored tabs?');
    return [];
  }
  try {
    await MetricsData.addAsync('rebuildAll: fixupTabsRestoredFromCache', fixupTabsRestoredFromCache(tabs, params.permanentStates, params.cache));
  }
  catch(e) {
    log(String(e), e.stack);
    throw e;
  }
  log('restoreTabsFromCacheInternal: done');
  if (configs.debug)
    Tab.dumpAll();
  return tabs;
}

async function fixupTabsRestoredFromCache(tabs, permanentStates, cachedTabs) {
  MetricsData.add('fixupTabsRestoredFromCache: start');
  if (tabs.length != cachedTabs.length)
    throw new Error(`fixupTabsRestoredFromCache: Mismatched number of tabs restored from cache, tabs=${tabs.length}, cachedTabs=${cachedTabs.length}`);
  log('fixupTabsRestoredFromCache start ', () => ({ tabs: tabs.map(dumpTab), cachedTabs }));
  const idMap = new Map();
  let remappedCount = 0;
  // step 1: build a map from old id to new id
  tabs = tabs.map((tab, index) => {
    const cachedTab = cachedTabs[index];
    const oldId     = cachedTab.id;
    tab = Tab.get(tab.id);
    log(`fixupTabsRestoredFromCache: remap ${oldId} => ${tab.id}`);
    idMap.set(oldId, tab);
    if (oldId != tab.id)
      remappedCount++;
    return tab;
  });
  if (remappedCount && remappedCount < tabs.length)
    throw new Error(`fixupTabsRestoredFromCache: not a window restoration, only ${remappedCount} tab(s) are restored (maybe restoration of closed tabs)`);
  MetricsData.add('fixupTabsRestoredFromCache: step 1 done.');
  // step 2: restore information of tabs
  // Do this from bottom to top, to reduce post operations for modified trees.
  // (Attaching a tab to an existing tree will trigger "update" task for
  // existing ancestors, but attaching existing subtree to a solo tab won't
  // trigger such tasks.)
  // See also: https://github.com/piroor/treestyletab/issues/2278#issuecomment-519387792
  for (let i = tabs.length - 1; i > -1; i--) {
    fixupTabRestoredFromCache(tabs[i], permanentStates[i], cachedTabs[i], idMap);
  }
  // step 3: restore collapsed/expanded state of tabs and finalize the
  // restoration process
  // Do this from top to bottom, because a tab can be placed under an
  // expanded parent but the parent can be placed under a collapsed parent.
  for (const tab of tabs) {
    fixupTabRestoredFromCachePostProcess(tab);
  }
  MetricsData.add('fixupTabsRestoredFromCache: step 2 done.');
}

function fixupTabRestoredFromCache(tab, permanentStates, cachedTab, idMap) {
  tab.$TST.clear();
  const tabStates = new Set([...cachedTab.$TST.states, ...permanentStates]);
  for (const state of Constants.kTAB_TEMPORARY_STATES) {
    tabStates.delete(state);
  }
  tab.$TST.states = tabStates;
  tab.$TST.attributes = cachedTab.$TST.attributes;

  log('fixupTabRestoredFromCache children: ', cachedTab.$TST.childIds);
  const childIds = mapAndFilter(cachedTab.$TST.childIds, oldId => {
    const tab = idMap.get(oldId);
    return tab?.id || undefined;
  });
  tab.$TST.children = childIds;
  if (childIds.length > 0)
    tab.$TST.setAttribute(Constants.kCHILDREN, `|${childIds.join('|')}|`);
  else
    tab.$TST.removeAttribute(Constants.kCHILDREN);
  log('fixupTabRestoredFromCache children: => ', tab.$TST.childIds);

  log('fixupTabRestoredFromCache parent: ', cachedTab.$TST.parentId);
  const parentTab = idMap.get(cachedTab.$TST.parentId) || null;
  tab.$TST.parent = parentTab;
  if (parentTab)
    tab.$TST.setAttribute(Constants.kPARENT, parentTab.id);
  else
    tab.$TST.removeAttribute(Constants.kPARENT);
  log('fixupTabRestoredFromCache parent: => ', tab.$TST.parentId);

  if (tab.discarded) {
    tab.$TST.addState(Constants.kTAB_STATE_PENDING);
  }

  tab.$TST.temporaryMetadata.set('treeStructureAlreadyRestoredFromSessionData', true);
}

function fixupTabRestoredFromCachePostProcess(tab) {
  const parentTab = tab.$TST.parent;
  if (parentTab &&
      (parentTab.$TST.collapsed ||
       parentTab.$TST.subtreeCollapsed)) {
    tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED);
    tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED_DONE);
  }
  else {
    tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED);
    tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED_DONE);
  }

  TabsStore.updateIndexesForTab(tab);
  TabsUpdate.updateTab(tab, tab, { forceApply: true, onlyApply: true });
}


// ===================================================================
// updating cache
// ===================================================================

async function updateWindowCache(owner, key, value) {
  if (!owner)
    return;

  if (configs.persistCachedTree) {
    try {
      if (value)
        await CacheStorage.setValue({
          windowId: owner.windowId,
          key,
          value,
          store: CacheStorage.BACKGROUND,
        });
      else
        await CacheStorage.deleteValue({
          windowId: owner.windowId,
          key,
          store: CacheStorage.BACKGROUND,
        });
      return;
    }
    catch(error) {
      console.log(`BackgroundCache.updateWindowCache for ${owner.windowId}/${key} failed: `, error.message, error.stack, error);
    }
  }

  const storageKey = `backgroundCache-${await UniqueId.ensureWindowId(owner.windowId)}-${key}`;
  if (value)
    mCaches[storageKey] = value;
  else
    delete mCaches[storageKey];
}

export function markWindowCacheDirtyFromTab(tab, akey) {
  const win = TabsStore.windows.get(tab.windowId);
  if (!win) // the window may be closed
    return;
  if (win.markWindowCacheDirtyFromTabTimeout)
    clearTimeout(win.markWindowCacheDirtyFromTabTimeout);
  win.markWindowCacheDirtyFromTabTimeout = setTimeout(() => {
    win.markWindowCacheDirtyFromTabTimeout = null;
    updateWindowCache(win.lastWindowCacheOwner, akey, true);
  }, 100);
}

async function getWindowCache(owner, key) {
  if (configs.persistCachedTree) {
    try {
      const value = await CacheStorage.getValue({
        windowId: owner.windowId,
        key,
        store: CacheStorage.BACKGROUND,
      });
      return value;
    }
    catch(error) {
      console.log(`BackgroundCache.getWindowCache for ${owner.windowId}/${key} failed: `, error.message, error.stack, error);
    }
  }

  const storageKey = `backgroundCache-${await UniqueId.ensureWindowId(owner.windowId)}-${key}`;
  return mCaches[storageKey];
}

function getWindowCacheOwner(windowId) {
  const tab = Tab.getFirstTab(windowId);
  if (!tab)
    return null;
  return {
    id:       tab.id,
    windowId: tab.windowId
  };
}

export async function reserveToCacheTree(windowId, trigger) {
  if (!mActivated ||
      !configs.useCachedTree)
    return;

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

  // If there is any opening (but not resolved its unique id yet) tab,
  // we are possibly restoring tabs. To avoid cache breakage before
  // restoration, we must wait until we know whether there is any other
  // restoring tab or not.
  if (Tab.needToWaitTracked(windowId))
    await Tab.waitUntilTrackedAll(windowId);

  if (win.promisedAllTabsRestored) // not restored yet
    return;

  if (!trigger && configs.debug)
    trigger = new Error().stack;

  log('reserveToCacheTree for window ', windowId, trigger);
  TabsInternalOperation.clearCache(win.lastWindowCacheOwner);

  if (trigger)
    reserveToCacheTree.triggers.add(trigger);

  if (win.waitingToCacheTree)
    clearTimeout(win.waitingToCacheTree);
  win.waitingToCacheTree = setTimeout(() => {
    const triggers = [...reserveToCacheTree.triggers];
    reserveToCacheTree.triggers.clear();
    cacheTree(windowId, triggers);
  }, 500);
}
reserveToCacheTree.triggers = new Set();

function cancelReservedCacheTree(windowId) {
  const win = TabsStore.windows.get(windowId);
  if (win?.waitingToCacheTree) {
    clearTimeout(win.waitingToCacheTree);
    delete win.waitingToCacheTree;
  }
}

async function cacheTree(windowId, triggers) {
  if (Tab.needToWaitTracked(windowId))
    await Tab.waitUntilTrackedAll(windowId);
  const win = TabsStore.windows.get(windowId);
  if (!win ||
      !configs.useCachedTree)
    return;
  const signature = getWindowSignature(Tab.getAllTabs(windowId));
  if (win.promisedAllTabsRestored) // not restored yet
    return;
  //log('save cache for ', windowId);
  win.lastWindowCacheOwner = getWindowCacheOwner(windowId);
  if (!win.lastWindowCacheOwner)
    return;
  const firstTab = Tab.getFirstTab(windowId);
  if (firstTab.incognito) { // never save cache for incognito windows
    updateWindowCache(win.lastWindowCacheOwner, Constants.kWINDOW_STATE_CACHED_TABS, null);
    return;
  }
  log('cacheTree for window ', windowId, triggers/*{ stack: configs.debug && new Error().stack }*/);
  updateWindowCache(win.lastWindowCacheOwner, Constants.kWINDOW_STATE_CACHED_TABS, {
    version:         kCONTENTS_VERSION,
    tabs:            TabsStore.windows.get(windowId).export(true).tabs,
    pinnedTabsCount: Tab.getPinnedTabs(windowId).length,
    signature
  });
}


// update cache on events

Tab.onCreated.addListener((tab, _info = {}) => {
  if (!tab.$TST.previousTab) { // it is a new cache owner
    const win = TabsStore.windows.get(tab.windowId);
    if (win.lastWindowCacheOwner)
      TabsInternalOperation.clearCache(win.lastWindowCacheOwner);
  }
  reserveToCacheTree(tab.windowId, 'tab created');
});

// Tree restoration for "Restore Previous Session"
Tab.onWindowRestoring.addListener(async ({ windowId, restoredCount }) => {
  if (!configs.useCachedTree)
    return;

  log('Tabs.onWindowRestoring ', { windowId, restoredCount });
  if (restoredCount == 1) {
    log('Tabs.onWindowRestoring: single tab restored');
    return;
  }

  log('Tabs.onWindowRestoring: continue ', windowId);
  MetricsData.add('Tabs.onWindowRestoring restore start');

  const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler());
  let restoredFromCache = false;
  try {
    restoredFromCache = await restoreWindowFromEffectiveWindowCache(windowId, {
      ignorePinnedTabs: true,
      owner: tabs[tabs.length - 1],
      tabs
    });
    log('Tabs.onWindowRestoring: restoredFromCache = ', restoredFromCache);
    MetricsData.add('Tabs.onWindowRestoring restore end');
  }
  catch(e) {
    log('Tabs.onWindowRestoring: FATAL ERROR while restoring tree from cache', String(e), e.stack);
  }
  if (!restoredFromCache) {
    // We need to treat tabs for a restored window as pending even if we failed to restore tabs from cache.
    // See https://github.com/piroor/treestyletab/issues/3794
    log('Tabs.onWindowRestoring: failsafe: setting discarded tabs as pending...');
    for (const tab of tabs) {
      const trackedTab = Tab.get(tab.id);
      if (trackedTab?.discarded) {
        trackedTab.$TST.addState(Constants.kTAB_STATE_PENDING);
      }
    }
  }
});

Tab.onRemoved.addListener((tab, info) => {
  if (!tab.$TST.previousTab) // the tab was the cache owner
    TabsInternalOperation.clearCache(tab);
  wait(0).then(() => {
  // "Restore Previous Session" closes some tabs at first, so we should not clear the old cache yet.
  // See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053
    reserveToCacheTree(info.windowId, 'tab removed');
  });
});

Tab.onMoved.addListener((tab, info) => {
  if (info.fromIndex == 0) // the tab is not the cache owner anymore
    TabsInternalOperation.clearCache(tab);
  reserveToCacheTree(info.windowId, 'tab moved');
});

Tab.onUpdated.addListener((tab, info) => {
  markWindowCacheDirtyFromTab(tab, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY);
  if ('url' in info)
    reserveToCacheTree(tab.windowId, 'tab updated');
});

Tab.onStateChanged.addListener((tab, state, _has) => {
  if (state == Constants.kTAB_STATE_STICKY)
    markWindowCacheDirtyFromTab(tab, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY);
});

Tree.onSubtreeCollapsedStateChanging.addListener(tab => {
  reserveToCacheTree(tab.windowId, 'subtree collapsed/expanded');
});

Tree.onAttached.addListener((tab, _info) => {
  wait(0).then(() => {
    // "Restore Previous Session" closes some tabs at first and it causes tree changes, so we should not clear the old cache yet.
    // See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053
    reserveToCacheTree(tab.windowId, 'tab attached to tree');
  });
});

Tree.onDetached.addListener((tab, _info) => {
  TabsInternalOperation.clearCache(tab);
  wait(0).then(() => {
    // "Restore Previous Session" closes some tabs at first and it causes tree changes, so we should not clear the old cache yet.
    // See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053
    reserveToCacheTree(tab.windowId, 'tab detached from tree');
  });
});

Tab.onPinned.addListener(tab => {
  reserveToCacheTree(tab.windowId, 'tab pinned');
});

Tab.onUnpinned.addListener(tab => {
  if (tab.$TST.previousTab) // the tab was the cache owner
    TabsInternalOperation.clearCache(tab);
  reserveToCacheTree(tab.windowId, 'tab unpinned');
});

Tab.onShown.addListener(tab => {
  reserveToCacheTree(tab.windowId, 'tab shown');
});

Tab.onHidden.addListener(tab => {
  reserveToCacheTree(tab.windowId, 'tab hidden');
});

browser.windows.onRemoved.addListener(async windowId => {
  try {
    CacheStorage.clearForWindow(windowId);
  }
  catch(_error) {
  }

  const storageKeyPart = `Cache-${await UniqueId.ensureWindowId(windowId)}-`;
  for (const key in mCaches) {
    if (key.includes(storageKeyPart))
      delete mCaches[key];
  }
});

function onConfigChange(key) {
  switch (key) {
    case 'useCachedTree':
    case 'persistCachedTree':
      browser.windows.getAll({
        populate:    true,
        windowTypes: ['normal']
      }).then(windows => {
        for (const win of windows) {
          const owner = win.tabs[win.tabs.length - 1];
          if (configs[key]) {
            reserveToCacheTree(win.id, 'config change');
          }
          else {
            TabsInternalOperation.clearCache(owner);
            location.reload();
          }
        }
      }).catch(ApiTabs.createErrorSuppressor());
      break;
  }
}
