/* 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/. */

// The ext-* files are imported into the same scopes.
/* import-globals-from ext-mail.js */

function sanitizePositionParams(params, window = null, positionOffset = 0) {
  if (params.left === null && params.top === null) {
    return;
  }

  if (params.left === null) {
    const baseLeft = window ? window.screenX : 0;
    params.left = baseLeft + positionOffset;
  }
  if (params.top === null) {
    const baseTop = window ? window.screenY : 0;
    params.top = baseTop + positionOffset;
  }

  // boundary check: don't put window out of visible area
  const baseWidth = window ? window.outerWidth : 0;
  const baseHeight = window ? window.outerHeight : 0;
  // Secure minimum size of an window should be same to the one
  // defined at nsGlobalWindowOuter::CheckSecurityWidthAndHeight.
  const minWidth = 100;
  const minHeight = 100;
  const width = Math.max(
    minWidth,
    params.width !== null ? params.width : baseWidth
  );
  const height = Math.max(
    minHeight,
    params.height !== null ? params.height : baseHeight
  );
  const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
    Ci.nsIScreenManager
  );
  const screen = screenManager.screenForRect(
    params.left,
    params.top,
    width,
    height
  );
  const availDeviceLeft = {};
  const availDeviceTop = {};
  const availDeviceWidth = {};
  const availDeviceHeight = {};
  screen.GetAvailRect(
    availDeviceLeft,
    availDeviceTop,
    availDeviceWidth,
    availDeviceHeight
  );
  const factor = screen.defaultCSSScaleFactor;
  const availLeft = Math.floor(availDeviceLeft.value / factor);
  const availTop = Math.floor(availDeviceTop.value / factor);
  const availWidth = Math.floor(availDeviceWidth.value / factor);
  const availHeight = Math.floor(availDeviceHeight.value / factor);
  params.left = Math.min(
    availLeft + availWidth - width,
    Math.max(availLeft, params.left)
  );
  params.top = Math.min(
    availTop + availHeight - height,
    Math.max(availTop, params.top)
  );
}

/**
 * Update the geometry of the mail window.
 *
 * @param {object} options
 *        An object containing new values for the window's geometry.
 * @param {integer} [options.left]
 *        The new pixel distance of the left side of the mail window from
 *        the left of the screen.
 * @param {integer} [options.top]
 *        The new pixel distance of the top side of the mail window from
 *        the top of the screen.
 * @param {integer} [options.width]
 *        The new pixel width of the window.
 * @param {integer} [options.height]
 *        The new pixel height of the window.
 */
function updateGeometry(window, options) {
  if (options.left !== null || options.top !== null) {
    let left = options.left === null ? window.screenX : options.left;
    let top = options.top === null ? window.screenY : options.top;
    window.moveTo(left, top);
  }

  if (options.width !== null || options.height !== null) {
    let width = options.width === null ? window.outerWidth : options.width;
    let height = options.height === null ? window.outerHeight : options.height;
    window.resizeTo(width, height);
  }
}

this.windows = class extends ExtensionAPIPersistent {
  onShutdown(isAppShutdown) {
    if (isAppShutdown) {
      return;
    }
    for (let window of Services.wm.getEnumerator("mail:extensionPopup")) {
      let uri = window.browser.browsingContext.currentURI;
      if (uri.scheme == "moz-extension" && uri.host == this.extension.uuid) {
        window.close();
      }
    }
  }

  windowEventRegistrar({ windowEvent, listener }) {
    let { extension } = this;
    return ({ context, fire }) => {
      let listener2 = async (window, ...args) => {
        if (!extension.canAccessWindow(window)) {
          return;
        }
        if (fire.wakeup) {
          await fire.wakeup();
        }
        listener({ context, fire, window }, ...args);
      };
      windowTracker.addListener(windowEvent, listener2);
      return {
        unregister() {
          windowTracker.removeListener(windowEvent, listener2);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    };
  }

  PERSISTENT_EVENTS = {
    // For primed persistent events (deactivated background), the context is only
    // available after fire.wakeup() has fulfilled (ensuring the convert() function
    // has been called) (handled by windowEventRegistrar).

    onCreated: this.windowEventRegistrar({
      windowEvent: "domwindowopened",
      listener: async ({ context, fire, window }) => {
        // Return the window only after it has been fully initialized.
        if (window.webExtensionWindowCreatePending) {
          await new Promise(resolve => {
            window.addEventListener("webExtensionWindowCreateDone", resolve, {
              once: true,
            });
          });
        }
        fire.async(this.extension.windowManager.convert(window));
      },
    }),

    onRemoved: this.windowEventRegistrar({
      windowEvent: "domwindowclosed",
      listener: ({ context, fire, window }) => {
        fire.async(windowTracker.getId(window));
      },
    }),

    onFocusChanged({ context, fire }) {
      let { extension } = this;
      // Keep track of the last windowId used to fire an onFocusChanged event
      let lastOnFocusChangedWindowId;
      let scheduledEvents = [];

      let listener = async event => {
        // Wait a tick to avoid firing a superfluous WINDOW_ID_NONE
        // event when switching focus between two Thunderbird windows.
        // Note: This is not working for Linux, where we still get the -1
        await Promise.resolve();

        let windowId = WindowBase.WINDOW_ID_NONE;
        let window = Services.focus.activeWindow;
        if (window) {
          if (!extension.canAccessWindow(window)) {
            return;
          }
          windowId = windowTracker.getId(window);
        }

        // Using a FIFO to keep order of events, in case the last one
        // gets through without being placed on the async callback stack.
        scheduledEvents.push(windowId);
        if (fire.wakeup) {
          await fire.wakeup();
        }
        let scheduledWindowId = scheduledEvents.shift();

        if (scheduledWindowId !== lastOnFocusChangedWindowId) {
          lastOnFocusChangedWindowId = scheduledWindowId;
          fire.async(scheduledWindowId);
        }
      };
      windowTracker.addListener("focus", listener);
      windowTracker.addListener("blur", listener);
      return {
        unregister() {
          windowTracker.removeListener("focus", listener);
          windowTracker.removeListener("blur", listener);
        },
        convert(newFire, extContext) {
          fire = newFire;
          context = extContext;
        },
      };
    },
  };

  getAPI(context) {
    const { extension } = context;
    const { windowManager } = extension;

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

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

        onFocusChanged: new EventManager({
          context,
          module: "windows",
          event: "onFocusChanged",
          extensionApi: this,
        }).api(),

        get(windowId, getInfo) {
          let window = windowTracker.getWindow(windowId, context);
          if (!window) {
            return Promise.reject({
              message: `Invalid window ID: ${windowId}`,
            });
          }
          return Promise.resolve(windowManager.convert(window, getInfo));
        },

        async getCurrent(getInfo) {
          let window = context.currentWindow || windowTracker.topWindow;
          if (window.document.readyState != "complete") {
            await new Promise(resolve =>
              window.addEventListener("load", resolve, { once: true })
            );
          }
          return windowManager.convert(window, getInfo);
        },

        async getLastFocused(getInfo) {
          let window = windowTracker.topWindow;
          if (window.document.readyState != "complete") {
            await new Promise(resolve =>
              window.addEventListener("load", resolve, { once: true })
            );
          }
          return windowManager.convert(window, getInfo);
        },

        getAll(getInfo) {
          let doNotCheckTypes = !getInfo || !getInfo.windowTypes;

          let windows = Array.from(windowManager.getAll(), win =>
            win.convert(getInfo)
          ).filter(
            win => doNotCheckTypes || getInfo.windowTypes.includes(win.type)
          );
          return Promise.resolve(windows);
        },

        async create(createData) {
          if (createData.incognito) {
            throw new ExtensionError("`incognito` is not supported");
          }

          let needResize =
            createData.left !== null ||
            createData.top !== null ||
            createData.width !== null ||
            createData.height !== null;
          if (needResize) {
            if (createData.state !== null && createData.state != "normal") {
              throw new ExtensionError(
                `"state": "${createData.state}" may not be combined with "left", "top", "width", or "height"`
              );
            }
            createData.state = "normal";
          }

          // 10px offset is same to Chromium
          sanitizePositionParams(createData, windowTracker.topNormalWindow, 10);

          let userContextId =
            Services.scriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
          if (createData.cookieStoreId) {
            userContextId = getUserContextIdForCookieStoreId(
              extension,
              createData.cookieStoreId
            );
          }
          let createWindowArgs = createData => {
            let allowScriptsToClose = !!createData.allowScriptsToClose;
            let url = createData.url || "about:blank";
            let urls = Array.isArray(url) ? url : [url];

            let args = Cc["@mozilla.org/array;1"].createInstance(
              Ci.nsIMutableArray
            );
            let actionData = {
              action: "open",
              allowScriptsToClose,
              tabs: urls.map(url => ({
                tabType: "contentTab",
                tabParams: { url, userContextId },
              })),
            };
            actionData.wrappedJSObject = actionData;
            args.appendElement(null);
            args.appendElement(actionData);
            return args;
          };

          let window;
          let wantNormalWindow =
            createData.type === null || createData.type == "normal";
          let features = ["chrome"];
          if (wantNormalWindow) {
            features.push("dialog=no", "all", "status", "toolbar");
          } else {
            // All other types create "popup"-type windows by default.
            // Use dialog=no to get minimize and maximize buttons (as chrome
            // does) and to allow the API to actually maximize the popup in
            // Linux.
            features.push(
              "dialog=no",
              "resizable",
              "minimizable",
              "titlebar",
              "close"
            );
            if (createData.left === null && createData.top === null) {
              features.push("centerscreen");
            }
          }

          let windowURL = wantNormalWindow
            ? "chrome://messenger/content/messenger.xhtml"
            : "chrome://messenger/content/extensionPopup.xhtml";
          if (createData.tabId) {
            if (createData.url) {
              return Promise.reject({
                message: "`tabId` may not be used in conjunction with `url`",
              });
            }

            if (createData.allowScriptsToClose) {
              return Promise.reject({
                message:
                  "`tabId` may not be used in conjunction with `allowScriptsToClose`",
              });
            }

            if (createData.cookieStoreId) {
              return Promise.reject({
                message:
                  "`tabId` may not be used in conjunction with `cookieStoreId`",
              });
            }

            let nativeTabInfo = tabTracker.getTab(createData.tabId);
            let tabmail =
              getTabBrowser(nativeTabInfo).ownerDocument.getElementById(
                "tabmail"
              );
            let targetType = wantNormalWindow ? null : "popup";
            window = tabmail.replaceTabWithWindow(nativeTabInfo, targetType)[0];
          } else {
            window = Services.ww.openWindow(
              null,
              windowURL,
              "_blank",
              features.join(","),
              wantNormalWindow ? null : createWindowArgs(createData)
            );
          }

          window.webExtensionWindowCreatePending = true;

          updateGeometry(window, createData);

          // TODO: focused, type

          // Wait till the newly created window is focused. On Linux the initial
          // "normal" state has been set once the window has been fully focused.
          // Setting a different state before the window is fully focused may cause
          // the initial state to be erroneously applied after the custom state has
          // been set.
          let focusPromise = new Promise(resolve => {
            if (Services.focus.activeWindow == window) {
              resolve();
            } else {
              window.addEventListener("focus", resolve, { once: true });
            }
          });

          let loadPromise = new Promise(resolve => {
            window.addEventListener("load", resolve, { once: true });
          });

          let titlePromise = new Promise(resolve => {
            window.addEventListener("pagetitlechanged", resolve, {
              once: true,
            });
          });

          await Promise.all([focusPromise, loadPromise, titlePromise]);

          let win = windowManager.getWrapper(window);

          if (
            [
              "minimized",
              "fullscreen",
              "docked",
              "normal",
              "maximized",
            ].includes(createData.state)
          ) {
            await win.setState(createData.state);
          }

          if (createData.titlePreface !== null) {
            win.setTitlePreface(createData.titlePreface);
          }

          // Update the title independently of a createData.titlePreface, to get
          // the title of the loaded document into the window title.
          if (win instanceof TabmailWindow) {
            win.window.document.getElementById("tabmail").setDocumentTitle();
          } else if (win.window.gBrowser?.updateTitlebar) {
            await win.window.gBrowser.updateTitlebar();
          }

          delete window.webExtensionWindowCreatePending;
          window.dispatchEvent(
            new window.CustomEvent("webExtensionWindowCreateDone")
          );
          return win.convert({ populate: true });
        },

        async update(windowId, updateInfo) {
          let needResize =
            updateInfo.left !== null ||
            updateInfo.top !== null ||
            updateInfo.width !== null ||
            updateInfo.height !== null;
          if (
            updateInfo.state !== null &&
            updateInfo.state != "normal" &&
            needResize
          ) {
            throw new ExtensionError(
              `"state": "${updateInfo.state}" may not be combined with "left", "top", "width", or "height"`
            );
          }

          let win = windowManager.get(windowId, context);
          if (!win) {
            throw new ExtensionError(`Invalid window ID: ${windowId}`);
          }

          // Update the window only after it has been fully initialized.
          if (win.window.webExtensionWindowCreatePending) {
            await new Promise(resolve => {
              win.window.addEventListener(
                "webExtensionWindowCreateDone",
                resolve,
                { once: true }
              );
            });
          }

          if (updateInfo.focused) {
            win.window.focus();
          }

          if (updateInfo.state !== null) {
            await win.setState(updateInfo.state);
          }

          if (updateInfo.drawAttention) {
            // Bug 1257497 - Firefox can't cancel attention actions.
            win.window.getAttention();
          }

          updateGeometry(win.window, updateInfo);

          if (updateInfo.titlePreface !== null) {
            win.setTitlePreface(updateInfo.titlePreface);
            if (win instanceof TabmailWindow) {
              win.window.document.getElementById("tabmail").setDocumentTitle();
            } else if (win.window.gBrowser?.updateTitlebar) {
              await win.window.gBrowser.updateTitlebar();
            }
          }

          // TODO: All the other properties, focused=false...

          return win.convert();
        },

        remove(windowId) {
          let window = windowTracker.getWindow(windowId, context);
          window.close();

          return new Promise(resolve => {
            let listener = () => {
              windowTracker.removeListener("domwindowclosed", listener);
              resolve();
            };
            windowTracker.addListener("domwindowclosed", listener);
          });
        },
        openDefaultBrowser(url) {
          let uri = null;
          try {
            uri = Services.io.newURI(url);
          } catch (e) {
            throw new ExtensionError(`Url "${url}" seems to be malformed.`);
          }
          if (!uri.schemeIs("http") && !uri.schemeIs("https")) {
            throw new ExtensionError(
              `Url scheme "${uri.scheme}" is not supported.`
            );
          }
          Cc["@mozilla.org/uriloader/external-protocol-service;1"]
            .getService(Ci.nsIExternalProtocolService)
            .loadURI(uri);
        },
      },
    };
  }
};
