/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* 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";

ChromeUtils.defineESModuleGetters(this, {
  GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs",
  mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs",
});

const getBrowserWindow = window => {
  return window.browsingContext.topChromeWindow;
};

const tabListener = {
  tabReadyInitialized: false,
  tabReadyPromises: new WeakMap(),
  initializingTabs: new WeakSet(),

  initTabReady() {
    if (!this.tabReadyInitialized) {
      windowTracker.addListener("progress", this);

      this.tabReadyInitialized = true;
    }
  },

  onLocationChange(browser, webProgress, request) {
    if (webProgress.isTopLevel) {
      const { tab } = browser.ownerGlobal;

      // Ignore initial about:blank
      if (!request && this.initializingTabs.has(tab)) {
        return;
      }

      // Now we are certain that the first page in the tab was loaded.
      this.initializingTabs.delete(tab);

      // browser.innerWindowID is now set, resolve the promises if any.
      const deferred = this.tabReadyPromises.get(tab);
      if (deferred) {
        deferred.resolve(tab);
        this.tabReadyPromises.delete(tab);
      }
    }
  },

  /**
   * Returns a promise that resolves when the tab is ready.
   * Tabs created via the `tabs.create` method are "ready" once the location
   * changes to the requested URL. Other tabs are assumed to be ready once their
   * inner window ID is known.
   *
   * @param {NativeTab} nativeTab The native tab object.
   * @returns {Promise} Resolves with the given tab once ready.
   */
  awaitTabReady(nativeTab) {
    let deferred = this.tabReadyPromises.get(nativeTab);
    if (!deferred) {
      deferred = Promise.withResolvers();
      if (
        !this.initializingTabs.has(nativeTab) &&
        (nativeTab.browser.innerWindowID ||
          nativeTab.browser.currentURI.spec === "about:blank")
      ) {
        deferred.resolve(nativeTab);
      } else {
        this.initTabReady();
        this.tabReadyPromises.set(nativeTab, deferred);
      }
    }
    return deferred.promise;
  },
};

this.tabs = class extends ExtensionAPIPersistent {
  tabEventRegistrar({ event, listener }) {
    const { extension } = this;
    const { tabManager } = extension;
    return ({ fire }) => {
      const listener2 = (eventName, eventData, ...args) => {
        if (!tabManager.canAccessTab(eventData.nativeTab)) {
          return;
        }

        listener(fire, eventData, ...args);
      };

      tabTracker.on(event, listener2);
      return {
        unregister() {
          tabTracker.off(event, listener2);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    };
  }

  PERSISTENT_EVENTS = {
    onActivated({ fire, context }) {
      const listener = (eventName, event) => {
        const { windowId, tabId, isPrivate } = event;
        if (isPrivate && !context.privateBrowsingAllowed) {
          return;
        }
        // In GeckoView each window has only one tab, so previousTabId is omitted.
        fire.async({ windowId, tabId });
      };

      mobileWindowTracker.on("tab-activated", listener);
      return {
        unregister() {
          mobileWindowTracker.off("tab-activated", listener);
        },
        convert(_fire, _context) {
          fire = _fire;
          context = _context;
        },
      };
    },
    onCreated: this.tabEventRegistrar({
      event: "tab-created",
      listener: (fire, event) => {
        const { tabManager } = this.extension;
        fire.async(tabManager.convert(event.nativeTab));
      },
    }),
    onRemoved: this.tabEventRegistrar({
      event: "tab-removed",
      listener: (fire, event) => {
        fire.async(event.tabId, {
          windowId: event.windowId,
          isWindowClosing: event.isWindowClosing,
        });
      },
    }),
    onUpdated({ fire }) {
      const { tabManager } = this.extension;
      const restricted = ["url", "favIconUrl", "title"];

      function sanitize(tab, changeInfo) {
        const result = {};
        let nonempty = false;
        for (const prop in changeInfo) {
          // In practice, changeInfo contains at most one property from
          // restricted. Therefore it is not necessary to cache the value
          // of tab.hasTabPermission outside the loop.
          if (!restricted.includes(prop) || tab.hasTabPermission) {
            nonempty = true;
            result[prop] = changeInfo[prop];
          }
        }
        return [nonempty, result];
      }

      const fireForTab = (tab, changed) => {
        const [needed, changeInfo] = sanitize(tab, changed);
        if (needed) {
          fire.async(tab.id, changeInfo, tab.convert());
        }
      };

      const listener = event => {
        const needed = [];
        let nativeTab;
        switch (event.type) {
          case "pagetitlechanged": {
            const window = getBrowserWindow(event.target.ownerGlobal);
            nativeTab = window.tab;

            needed.push("title");
            break;
          }

          case "DOMAudioPlaybackStarted":
          case "DOMAudioPlaybackStopped": {
            const window = event.target.ownerGlobal;
            nativeTab = window.tab;
            needed.push("audible");
            break;
          }
        }

        if (!nativeTab) {
          return;
        }

        const tab = tabManager.getWrapper(nativeTab);
        const changeInfo = {};
        for (const prop of needed) {
          changeInfo[prop] = tab[prop];
        }

        fireForTab(tab, changeInfo);
      };

      const statusListener = ({ browser, status, url }) => {
        const { tab } = browser.ownerGlobal;
        if (tab) {
          const changed = { status };
          if (url) {
            changed.url = url;
          }

          fireForTab(tabManager.wrapTab(tab), changed);
        }
      };

      windowTracker.addListener("status", statusListener);
      windowTracker.addListener("pagetitlechanged", listener);

      return {
        unregister() {
          windowTracker.removeListener("status", statusListener);
          windowTracker.removeListener("pagetitlechanged", listener);
        },
        convert(_fire) {
          fire = _fire;
        },
      };
    },
  };

  getAPI(context) {
    const { extension } = context;
    const { tabManager } = extension;
    const extensionApi = this;
    const module = "tabs";

    function getTabOrActive(tabId) {
      if (tabId !== null) {
        return tabTracker.getTab(tabId);
      }
      return tabTracker.activeTab;
    }

    async function promiseTabWhenReady(tabId) {
      let tab;
      if (tabId !== null) {
        tab = tabManager.get(tabId);
      } else {
        tab = tabManager.getWrapper(tabTracker.activeTab);
      }
      if (!tab) {
        throw new ExtensionError(
          tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}`
        );
      }

      await tabListener.awaitTabReady(tab.nativeTab);

      return tab;
    }

    function loadURIInTab(nativeTab, url) {
      const { browser } = nativeTab;

      let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
      let { principal } = context;
      const isAboutUrl = url.startsWith("about:");
      if (
        isAboutUrl ||
        (ExtensionUtils.isExtensionUrl(url) &&
          !context.checkLoadURL(url, { dontReportErrors: true }))
      ) {
        // Falling back to content here as about: requires it, however is safe.
        principal =
          Services.scriptSecurityManager.getLoadContextContentPrincipal(
            Services.io.newURI(url),
            browser.loadContext
          );
      }
      if (isAboutUrl) {
        // Make sure things like about:blank and other about: URIs never
        // inherit, and instead always get a NullPrincipal.
        loadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
      }

      browser.fixupAndLoadURIString(url, {
        loadFlags,
        triggeringPrincipal: principal,
      });
    }

    return {
      tabs: {
        onActivated: new EventManager({
          context,
          module,
          event: "onActivated",
          extensionApi,
        }).api(),

        onCreated: new EventManager({
          context,
          module,
          event: "onCreated",
          extensionApi,
        }).api(),

        /**
         * Since multiple tabs currently can't be highlighted, onHighlighted
         * essentially acts an alias for tabs.onActivated but returns
         * the tabId in an array to match the API.
         *
         * @see  https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted
         */
        onHighlighted: makeGlobalEvent(
          context,
          "tabs.onHighlighted",
          "Tab:Selected",
          (fire, data) => {
            const tab = tabManager.get(data.id);

            fire.async({ tabIds: [tab.id], windowId: tab.windowId });
          }
        ),

        // Some events below are not be persisted because they are not implemented.
        // They do not have an "extensionApi" property with an entry in
        // PERSISTENT_EVENTS, but instead an empty "register" method.
        onAttached: new EventManager({
          context,
          name: "tabs.onAttached",
          register: () => {
            return () => {};
          },
        }).api(),

        onDetached: new EventManager({
          context,
          name: "tabs.onDetached",
          register: () => {
            return () => {};
          },
        }).api(),

        onRemoved: new EventManager({
          context,
          module,
          event: "onRemoved",
          extensionApi,
        }).api(),

        onReplaced: new EventManager({
          context,
          name: "tabs.onReplaced",
          register: () => {
            return () => {};
          },
        }).api(),

        onMoved: new EventManager({
          context,
          name: "tabs.onMoved",
          register: () => {
            return () => {};
          },
        }).api(),

        onUpdated: new EventManager({
          context,
          module,
          event: "onUpdated",
          extensionApi,
        }).api(),

        async create({
          active,
          cookieStoreId,
          discarded,
          index,
          openInReaderMode,
          pinned,
          url,
        } = {}) {
          if (active === null) {
            active = true;
          }

          tabListener.initTabReady();

          if (url !== null) {
            url = context.uri.resolve(url);

            if (
              !ExtensionUtils.isExtensionUrl(url) &&
              !context.checkLoadURL(url, { dontReportErrors: true })
            ) {
              return Promise.reject({ message: `Illegal URL: ${url}` });
            }
          }

          if (cookieStoreId) {
            cookieStoreId = getUserContextIdForCookieStoreId(
              extension,
              cookieStoreId,
              false // TODO bug 1372178: support creation of private browsing tabs
            );
          }
          cookieStoreId = cookieStoreId ? cookieStoreId.toString() : undefined;

          const nativeTab = await GeckoViewTabBridge.createNewTab({
            extensionId: context.extension.id,
            createProperties: {
              active,
              cookieStoreId,
              discarded,
              index,
              openInReaderMode,
              pinned,
              url,
            },
          });

          // Make sure things like about:blank URIs never inherit,
          // and instead always get a NullPrincipal.
          if (url !== null) {
            tabListener.initializingTabs.add(nativeTab);
          } else {
            url = "about:blank";
          }

          loadURIInTab(nativeTab, url);

          if (active) {
            const newWindow = nativeTab.browser.ownerGlobal;
            mobileWindowTracker.setTabActive(newWindow, true);
          }

          return tabManager.convert(nativeTab);
        },

        async remove(tabs) {
          if (!Array.isArray(tabs)) {
            tabs = [tabs];
          }

          await Promise.all(
            tabs.map(async tabId => {
              const windowId = GeckoViewTabBridge.tabIdToWindowId(tabId);
              const window = windowTracker.getWindow(windowId, context, false);
              if (!window) {
                throw new ExtensionError(`Invalid tab ID ${tabId}`);
              }
              await GeckoViewTabBridge.closeTab({
                window,
                extensionId: context.extension.id,
              });
            })
          );
        },

        async update(
          tabId,
          { active, autoDiscardable, highlighted, muted, pinned, url } = {}
        ) {
          const nativeTab = getTabOrActive(tabId);
          const window = nativeTab.browser.ownerGlobal;

          if (url !== null) {
            url = context.uri.resolve(url);

            if (
              !ExtensionUtils.isExtensionUrl(url) &&
              !context.checkLoadURL(url, { dontReportErrors: true })
            ) {
              return Promise.reject({ message: `Illegal URL: ${url}` });
            }
          }

          await GeckoViewTabBridge.updateTab({
            window,
            extensionId: context.extension.id,
            updateProperties: {
              active,
              autoDiscardable,
              highlighted,
              muted,
              pinned,
              url,
            },
          });

          if (url !== null) {
            loadURIInTab(nativeTab, url);
          }

          // FIXME: openerTabId, successorTabId
          if (active) {
            mobileWindowTracker.setTabActive(window, true);
          }

          return tabManager.convert(nativeTab);
        },

        async reload(tabId, reloadProperties) {
          const nativeTab = getTabOrActive(tabId);

          let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
          if (reloadProperties && reloadProperties.bypassCache) {
            flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
          }
          nativeTab.browser.reloadWithFlags(flags);
        },

        async get(tabId) {
          return tabManager.get(tabId).convert();
        },

        async getCurrent() {
          if (context.tabId) {
            return tabManager.get(context.tabId).convert();
          }
        },

        async query(queryInfo) {
          return Array.from(tabManager.query(queryInfo, context), tab =>
            tab.convert()
          );
        },

        async captureTab(tabId, options) {
          const nativeTab = getTabOrActive(tabId);
          await tabListener.awaitTabReady(nativeTab);

          const { browser } = nativeTab;
          const tab = tabManager.wrapTab(nativeTab);
          return tab.capture(context, browser.fullZoom, options);
        },

        async captureVisibleTab(windowId, options) {
          const window =
            windowId == null
              ? windowTracker.topWindow
              : windowTracker.getWindow(windowId, context);

          const tab = tabManager.getWrapper(window.tab);
          if (
            !extension.hasPermission("<all_urls>") &&
            !tab.hasActiveTabPermission
          ) {
            throw new ExtensionError("Missing activeTab permission");
          }
          await tabListener.awaitTabReady(tab.nativeTab);
          const zoom = window.browsingContext.fullZoom;

          return tab.capture(context, zoom, options);
        },

        async detectLanguage(tabId) {
          const tab = await promiseTabWhenReady(tabId);
          const results = await tab.queryContent("DetectLanguage", {});
          return results[0];
        },

        async executeScript(tabId, details) {
          const tab = await promiseTabWhenReady(tabId);

          return tab.executeScript(context, details);
        },

        async insertCSS(tabId, details) {
          const tab = await promiseTabWhenReady(tabId);

          return tab.insertCSS(context, details);
        },

        async removeCSS(tabId, details) {
          const tab = await promiseTabWhenReady(tabId);

          return tab.removeCSS(context, details);
        },

        goForward(tabId) {
          const { browser } = getTabOrActive(tabId);
          browser.goForward(false);
        },

        goBack(tabId) {
          const { browser } = getTabOrActive(tabId);
          browser.goBack(false);
        },
      },
    };
  }
};
