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

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { Log } from "resource://gre/modules/Log.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

if (AppConstants.platform == "android") {
  ChromeUtils.defineESModuleGetters(lazy, {
    AndroidAppender: "resource://gre/modules/AndroidLog.sys.mjs",
  });
}

export var GeckoViewUtils = {
  /**
   * Define a lazy getter that loads an object from external code, and
   * optionally handles observer and/or message manager notifications for the
   * object, so the object only loads when a notification is received.
   *
   * @param scope     Scope for holding the loaded object.
   * @param name      Name of the object to load.
   * @param service   If specified, load the object from a JS component; the
   *                  component must include the line
   *                  "this.wrappedJSObject = this;" in its constructor.
   * @param module    If specified, load the object from a JS module.
   * @param init      Optional post-load initialization function.
   * @param observers If specified, listen to specified observer notifications.
   * @param ppmm      If specified, listen to specified process messages.
   * @param mm        If specified, listen to specified frame messages.
   * @param ged       If specified, listen to specified global EventDispatcher events.
   * @param once      if true, only listen to the specified
   *                  events/messages/notifications once.
   */
  addLazyGetter(
    scope,
    name,
    { service, module, handler, observers, ppmm, mm, ged, init, once }
  ) {
    ChromeUtils.defineLazyGetter(scope, name, _ => {
      let ret = undefined;
      if (module) {
        ret = ChromeUtils.importESModule(module)[name];
      } else if (service) {
        ret = Cc[service].getService(Ci.nsISupports).wrappedJSObject;
      } else if (typeof handler === "function") {
        ret = {
          handleEvent: handler,
          observe: handler,
          onEvent: handler,
          receiveMessage: handler,
        };
      } else if (handler) {
        ret = handler;
      }
      if (ret && init) {
        init.call(scope, ret);
      }
      return ret;
    });

    if (observers) {
      const observer = (subject, topic, data) => {
        Services.obs.removeObserver(observer, topic);
        if (!once) {
          Services.obs.addObserver(scope[name], topic);
        }
        scope[name].observe(subject, topic, data); // Explicitly notify new observer
      };
      observers.forEach(topic => Services.obs.addObserver(observer, topic));
    }

    if (!this.IS_PARENT_PROCESS) {
      // ppmm, mm, and ged are only available in the parent process.
      return;
    }

    const addMMListener = (target, names) => {
      const listener = msg => {
        target.removeMessageListener(msg.name, listener);
        if (!once) {
          target.addMessageListener(msg.name, scope[name]);
        }
        scope[name].receiveMessage(msg);
      };
      names.forEach(msg => target.addMessageListener(msg, listener));
    };
    if (ppmm) {
      addMMListener(Services.ppmm, ppmm);
    }
    if (mm) {
      addMMListener(Services.mm, mm);
    }

    if (ged) {
      const listener = (event, data, callback) => {
        lazy.EventDispatcher.instance.unregisterListener(listener, event);
        if (!once) {
          lazy.EventDispatcher.instance.registerListener(scope[name], event);
        }
        scope[name].onEvent(event, data, callback);
      };
      lazy.EventDispatcher.instance.registerListener(listener, ged);
    }
  },

  _addLazyListeners(events, handler, scope, name, addFn, handleFn) {
    if (!handler) {
      handler = _ =>
        Array.isArray(name) ? name.map(n => scope[n]) : scope[name];
    }
    const listener = (...args) => {
      let handlers = handler(...args);
      if (!handlers) {
        return;
      }
      if (!Array.isArray(handlers)) {
        handlers = [handlers];
      }
      handleFn(handlers, listener, args);
    };
    if (Array.isArray(events)) {
      addFn(events, listener);
    } else {
      addFn([events], listener);
    }
  },

  /**
   * Add lazy event listeners that only load the actual handler when an event
   * is being handled.
   *
   * @param target  Event target for the event listeners.
   * @param events  Event name as a string or array.
   * @param handler If specified, function that, for a given event, returns the
   *                actual event handler as an object or an array of objects.
   *                If handler is not specified, the actual event handler is
   *                specified using the scope and name pair.
   * @param scope   See handler.
   * @param name    See handler.
   * @param options Options for addEventListener.
   */
  addLazyEventListener(target, events, { handler, scope, name, options }) {
    this._addLazyListeners(
      events,
      handler,
      scope,
      name,
      (events, listener) => {
        events.forEach(event =>
          target.addEventListener(event, listener, options)
        );
      },
      (handlers, listener, args) => {
        if (!options || !options.once) {
          target.removeEventListener(args[0].type, listener, options);
          handlers.forEach(handler =>
            target.addEventListener(args[0].type, handler, options)
          );
        }
        handlers.forEach(handler => handler.handleEvent(args[0]));
      }
    );
  },

  /**
   * Add lazy pref observers, and only load the actual handler once the pref
   * value changes from default, and every time the pref value changes
   * afterwards.
   *
   * @param aPrefs  Prefs as an object or array. Each pref object has fields
   *                "name" and "default", indicating the name and default value
   *                of the pref, respectively.
   * @param handler If specified, function that, for a given pref, returns the
   *                actual event handler as an object or an array of objects.
   *                If handler is not specified, the actual event handler is
   *                specified using the scope and name pair.
   * @param scope   See handler.
   * @param name    See handler.
   * @param once    If true, only observe the specified prefs once.
   */
  addLazyPrefObserver(aPrefs, { handler, scope, name, once }) {
    this._addLazyListeners(
      aPrefs,
      handler,
      scope,
      name,
      (prefs, observer) => {
        prefs.forEach(pref => Services.prefs.addObserver(pref.name, observer));
        prefs.forEach(pref => {
          if (pref.default === undefined) {
            return;
          }
          let value;
          switch (typeof pref.default) {
            case "string":
              value = Services.prefs.getCharPref(pref.name, pref.default);
              break;
            case "number":
              value = Services.prefs.getIntPref(pref.name, pref.default);
              break;
            case "boolean":
              value = Services.prefs.getBoolPref(pref.name, pref.default);
              break;
          }
          if (pref.default !== value) {
            // Notify observer if value already changed from default.
            observer(Services.prefs, "nsPref:changed", pref.name);
          }
        });
      },
      (handlers, observer, args) => {
        if (!once) {
          Services.prefs.removeObserver(args[2], observer);
          handlers.forEach(() => Services.prefs.addObserver(args[2], observer));
        }
        handlers.forEach(handler => handler.observe(...args));
      }
    );
  },

  getRootDocShell(aWin) {
    if (!aWin) {
      return null;
    }
    let docShell;
    try {
      docShell = aWin.QueryInterface(Ci.nsIDocShell);
    } catch (e) {
      docShell = aWin.docShell;
    }
    return docShell.rootTreeItem.QueryInterface(Ci.nsIInterfaceRequestor);
  },

  /**
   * Return the outermost chrome DOM window (the XUL window) for a given DOM
   * window, in the parent process.
   *
   * @param aWin a DOM window.
   */
  getChromeWindow(aWin) {
    const docShell = this.getRootDocShell(aWin);
    return docShell && docShell.domWindow;
  },

  /**
   * Return the content frame message manager (aka the frame script global
   * object) for a given DOM window, in a child process.
   *
   * @param aWin a DOM window.
   */
  getContentFrameMessageManager(aWin) {
    const docShell = this.getRootDocShell(aWin);
    return docShell && docShell.getInterface(Ci.nsIBrowserChild).messageManager;
  },

  /**
   * Return the per-nsWindow EventDispatcher for a given DOM window, in either
   * the parent process or a child process.
   *
   * @param aWin a DOM window.
   */
  getDispatcherForWindow(aWin) {
    try {
      if (!this.IS_PARENT_PROCESS) {
        const mm = this.getContentFrameMessageManager(aWin.top || aWin);
        return mm && lazy.EventDispatcher.forMessageManager(mm);
      }
      const win = this.getChromeWindow(aWin.top || aWin);
      if (!win.closed) {
        return win.WindowEventDispatcher || lazy.EventDispatcher.for(win);
      }
    } catch (e) {}
    return null;
  },

  /**
   * Return promise for waiting for finishing PanZoomState.
   *
   * @param aWindow a DOM window.
   * @return promise
   */
  waitForPanZoomState(aWindow) {
    return new Promise((resolve, reject) => {
      if (
        !aWindow?.windowUtils.asyncPanZoomEnabled ||
        !Services.prefs.getBoolPref("apz.zoom-to-focused-input.enabled")
      ) {
        // No zoomToFocusedInput.
        resolve();
        return;
      }

      let timerId = 0;

      const panZoomState = (aSubject, aTopic, aData) => {
        if (timerId != 0) {
          // aWindow may be dead object now.
          try {
            lazy.clearTimeout(timerId);
          } catch (e) {}
          timerId = 0;
        }

        if (aData === "NOTHING") {
          Services.obs.removeObserver(panZoomState, "PanZoom:StateChange");
          resolve();
        }
      };

      Services.obs.addObserver(panZoomState, "PanZoom:StateChange");

      // "GeckoView:ZoomToInput" has the timeout as 500ms when window isn't
      // resized (it means on-screen-keyboard is already shown).
      // So after up to 500ms, APZ event is sent. So we need to wait for more
      // 500ms.
      timerId = lazy.setTimeout(() => {
        // PanZoom state isn't changed. zoomToFocusedInput will return error.
        Services.obs.removeObserver(panZoomState, "PanZoom:StateChange");
        reject();
      }, 600);
    });
  },

  /**
   * Add logging functions to the specified scope that forward to the given
   * Log.sys.mjs logger. Currently "debug" and "warn" functions are supported. To
   * log something, call the function through a template literal:
   *
   *   function foo(bar, baz) {
   *     debug `hello world`;
   *     debug `foo called with ${bar} as bar`;
   *     warn `this is a warning for ${baz}`;
   *   }
   *
   * An inline format can also be used for logging:
   *
   *   let bar = 42;
   *   do_something(bar); // No log.
   *   do_something(debug.foo = bar); // Output "foo = 42" to the log.
   *
   * @param aTag Name of the Log.sys.mjs logger to forward logs to.
   * @param aScope Scope to add the logging functions to.
   */
  initLogging(aTag, aScope) {
    aScope = aScope || {};
    const tag = "GeckoView." + aTag.replace(/^GeckoView\.?/, "");

    // Only provide two levels for simplicity.
    // For "info", use "debug" instead.
    // For "error", throw an actual JS error instead.
    for (const level of ["DEBUG", "WARN"]) {
      const log = (strings, ...exprs) =>
        this._log(log.logger, level, strings, exprs);

      ChromeUtils.defineLazyGetter(log, "logger", _ => {
        const logger = Log.repository.getLogger(tag);
        logger.parent = this.rootLogger;
        return logger;
      });

      aScope[level.toLowerCase()] = new Proxy(log, {
        set: (obj, prop, value) => obj([prop + " = ", ""], value) || true,
      });
    }
    return aScope;
  },

  get rootLogger() {
    if (!this._rootLogger) {
      this._rootLogger = Log.repository.getLogger("GeckoView");
      // On Android, we'll log to the native android logcat output using
      // __android_log_write. On iOS, fall back to a dump appender.
      if (AppConstants.platform == "android") {
        this._rootLogger.addAppender(new lazy.AndroidAppender());
      } else {
        this._rootLogger.addAppender(new Log.DumpAppender());
      }
      this._rootLogger.manageLevelFromPref("geckoview.logging");
    }
    return this._rootLogger;
  },

  _log(aLogger, aLevel, aStrings, aExprs) {
    if (!Array.isArray(aStrings)) {
      const [, file, line] = new Error().stack.match(/.*\n.*\n.*@(.*):(\d+):/);
      throw Error(
        `Expecting template literal: ${aLevel} \`foo \${bar}\``,
        file,
        +line
      );
    }

    if (aLogger.level > Log.Level.Numbers[aLevel]) {
      // Log disabled.
      return;
    }

    // Do some GeckoView-specific formatting:
    // * Remove newlines so long log lines can be put into multiple lines:
    //   debug `foo=${foo}
    //          bar=${bar}`;
    const strs = Array.from(aStrings);
    const regex = /\n\s*/g;
    for (let i = 0; i < strs.length; i++) {
      strs[i] = strs[i].replace(regex, " ");
    }

    // * Heuristically format flags as hex.
    // * Heuristically format nsresult as string name or hex.
    for (let i = 0; i < aExprs.length; i++) {
      const expr = aExprs[i];
      switch (typeof expr) {
        case "number":
          if (expr > 0 && /\ba?[fF]lags?[\s=:]+$/.test(strs[i])) {
            // Likely a flag; display in hex.
            aExprs[i] = `0x${expr.toString(0x10)}`;
          } else if (expr >= 0 && /\b(a?[sS]tatus|rv)[\s=:]+$/.test(strs[i])) {
            // Likely an nsresult; display in name or hex.
            aExprs[i] = `0x${expr.toString(0x10)}`;
            for (const name in Cr) {
              if (expr === Cr[name]) {
                aExprs[i] = name;
                break;
              }
            }
          }
          break;
      }
    }

    aLogger[aLevel.toLowerCase()](strs, ...aExprs);
  },

  /**
   * Checks whether the principal is supported for permissions.
   *
   * @param {nsIPrincipal} principal
   *        The principal to check.
   *
   * @return {boolean} if the principal is supported.
   */
  isSupportedPermissionsPrincipal(principal) {
    if (!principal) {
      return false;
    }
    if (!(principal instanceof Ci.nsIPrincipal)) {
      throw new Error(
        "Argument passed as principal is not an instance of Ci.nsIPrincipal"
      );
    }
    return this.isSupportedPermissionsScheme(principal.scheme);
  },

  /**
   * Checks whether we support managing permissions for a specific scheme.
   *
   * @param {string} scheme - Scheme to test.
   * @returns {boolean} Whether the scheme is supported.
   */
  isSupportedPermissionsScheme(scheme) {
    return ["http", "https", "moz-extension", "file"].includes(scheme);
  },

  /**
   * Attach nsIOpenWindowInfo when opening GeckoSession
   *
   * @param {string} aSessionId A session id
   * @param {nsIOpenWindowInfo} aOpenWindowInfo Attached nsIOpendWindowInfo
   * @param {string} aName A window name
   * @returns {Promise} resolved when nsIOpenWindowInfo is attached
   */
  waitAndSetupWindow(aSessionId, aOpenWindowInfo, aName) {
    if (!aSessionId) {
      return Promise.reject();
    }

    return new Promise((resolve, reject) => {
      const handler = {
        observe(aSubject, aTopic) {
          if (
            aTopic === "geckoview-window-created" &&
            aSubject.name === aSessionId
          ) {
            // This value will be read by nsFrameLoader while it is being initialized.
            aSubject.browser.openWindowInfo = aOpenWindowInfo;

            // Gecko will use this attribute to set the name of the opened window.
            if (aName) {
              aSubject.browser.setAttribute("name", aName);
            }

            if (
              !aOpenWindowInfo.isRemote &&
              aSubject.browser.hasAttribute("remote")
            ) {
              // We cannot start in remote mode when we have an opener.
              aSubject.browser.setAttribute("remote", "false");
              aSubject.browser.removeAttribute("remoteType");
            }
            Services.obs.removeObserver(handler, "geckoview-window-created");
            if (!aSubject) {
              reject();
              return;
            }
            resolve(aSubject);
          }
        },
      };

      // This event is emitted from createBrowser() in geckoview.js
      Services.obs.addObserver(handler, "geckoview-window-created");
    });
  },
};

ChromeUtils.defineLazyGetter(
  GeckoViewUtils,
  "IS_PARENT_PROCESS",
  _ => Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT
);
