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

/**
 * This module exports a provider class that is used for providers created by
 * extensions.
 */

import {
  SkippableTimer,
  UrlbarProvider,
  UrlbarUtils,
} from "resource:///modules/UrlbarUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
  UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
  UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
  UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs",
});

// The set of `UrlbarQueryContext` properties that aren't serializable.
const NONSERIALIZABLE_CONTEXT_PROPERTIES = new Set(["view"]);

/**
 * The browser.urlbar extension API allows extensions to create their own urlbar
 * providers.  The results from extension providers are integrated into the
 * urlbar view just like the results from providers that are built into Firefox.
 *
 * This class is the interface between the provider-related parts of the
 * browser.urlbar extension API implementation and our internal urlbar
 * implementation.  The API implementation should use this class to manage
 * providers created by extensions.  All extension providers must be instances
 * of this class.
 *
 * When an extension requires a provider, the API implementation should call
 * getOrCreate() to get or create it.  When an extension adds an event listener
 * related to a provider, the API implementation should call setEventListener()
 * to register its own event listener with the provider.
 */
export class UrlbarProviderExtension extends UrlbarProvider {
  /**
   * Returns the extension provider with the given name, creating it first if
   * it doesn't exist.
   *
   * @param {string} name
   *   The provider name.
   * @returns {UrlbarProviderExtension}
   *   The provider.
   */
  static getOrCreate(name) {
    let provider = lazy.UrlbarProvidersManager.getProvider(name);
    if (!provider) {
      provider = new UrlbarProviderExtension(name);
      lazy.UrlbarProvidersManager.registerProvider(provider);
    }
    return provider;
  }

  /**
   * Constructor.
   *
   * @param {string} name
   *   The provider's name.
   */
  constructor(name) {
    super();
    this._name = name;
    this._eventListeners = new Map();
    this.behavior = "inactive";
  }

  /**
   * The provider's name.
   *
   * @returns {string}
   */
  get name() {
    return this._name;
  }

  /**
   * The provider's type.  The type of extension providers is always
   * UrlbarUtils.PROVIDER_TYPE.EXTENSION.
   *
   * @returns {UrlbarUtils.PROVIDER_TYPE}
   */
  get type() {
    return UrlbarUtils.PROVIDER_TYPE.EXTENSION;
  }

  /**
   * Whether the provider should be invoked for the given context.  If this
   * method returns false, the providers manager won't start a query with this
   * provider, to save on resources.
   *
   * @param {UrlbarQueryContext} context
   *   The query context object.
   * @returns {boolean}
   *   Whether this provider should be invoked for the search.
   */
  isActive(context) {
    return this.behavior != "inactive";
  }

  /**
   * Gets the provider's priority.
   *
   * @param {UrlbarQueryContext} context
   *   The query context object.
   * @returns {number}
   *   The provider's priority for the given query.
   */
  getPriority(context) {
    // We give restricting extension providers a very high priority so that they
    // normally override all built-in providers, but not Infinity so that we can
    // still override them if necessary.
    return this.behavior == "restricting" ? 999 : 0;
  }

  /**
   * Sets the listener function for an event.  The extension API implementation
   * should call this from its EventManager.register() implementations.  Since
   * EventManager.register() is called at most only once for each extension
   * event (the first time the extension adds a listener for the event), each
   * provider instance needs at most only one listener per event, and that's why
   * this method is named setEventListener instead of addEventListener.
   *
   * The given listener function may return a promise that's resolved once the
   * extension responds to the event, or if the event requires no response from
   * the extension, it may return a non-promise value (possibly nothing).
   *
   * To remove the previously set listener, call this method again but pass null
   * as the listener function.
   *
   * The event name should be one of the following:
   *
   *   behaviorRequested
   *     This event is fired when the provider's behavior is needed from the
   *     extension.  The listener should return a behavior string.
   *   queryCanceled
   *     This event is fired when an ongoing query is canceled.  The listener
   *     shouldn't return anything.
   *   resultsRequested
   *     This event is fired when the provider's results are needed from the
   *     extension.  The listener should return an array of results.
   *
   * @param {string} eventName
   *   The name of the event to listen to.
   * @param {Function} listener
   *   The function that will be called when the event is fired.
   */
  setEventListener(eventName, listener) {
    if (listener) {
      this._eventListeners.set(eventName, listener);
    } else {
      this._eventListeners.delete(eventName);
      if (!this._eventListeners.size) {
        lazy.UrlbarProvidersManager.unregisterProvider(this);
      }
    }
  }

  /**
   * This method is called by the providers manager before a query starts to
   * update each extension provider's behavior.  It fires the behaviorRequested
   * event.
   *
   * @param {UrlbarQueryContext} context
   *   The query context.
   */
  async updateBehavior(context) {
    let behavior = await this._notifyListener(
      "behaviorRequested",
      makeSerializable(context)
    );
    if (behavior) {
      this.behavior = behavior;
    }
  }

  /**
   * This is called only for dynamic result types, when the urlbar view updates
   * the view of one of the results of the provider.  It should return an object
   * describing the view update.  See the base UrlbarProvider class for more.
   *
   * @param {UrlbarResult} result The result whose view will be updated.
   * @param {Map} idsByName
   *   A Map from an element's name, as defined by the provider; to its ID in
   *   the DOM, as defined by the browser.
   * @returns {object} An object describing the view update.
   */
  async getViewUpdate(result, idsByName) {
    return this._notifyListener("getViewUpdate", result, idsByName);
  }

  /**
   * This method is called by the providers manager when a query starts to fetch
   * each extension provider's results.  It fires the resultsRequested event.
   *
   * @param {UrlbarQueryContext} context
   *   The query context.
   * @param {Function} addCallback
   *   The callback invoked by this method to add each result.
   */
  async startQuery(context, addCallback) {
    let extResults = await this._notifyListener(
      "resultsRequested",
      makeSerializable(context)
    );
    if (extResults) {
      for (let extResult of extResults) {
        let result = await this._makeUrlbarResult(context, extResult).catch(
          ex => this.logger.error(ex)
        );
        if (result) {
          addCallback(this, result);
        }
      }
    }
  }

  /**
   * This method is called by the providers manager when an ongoing query is
   * canceled.  It fires the queryCanceled event.
   *
   * @param {UrlbarQueryContext} context
   *   The query context.
   */
  cancelQuery(context) {
    this._notifyListener("queryCanceled", makeSerializable(context));
  }

  #pickResult(result, element) {
    let dynamicElementName = "";
    if (element && result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC) {
      dynamicElementName = element.getAttribute("name");
    }
    this._notifyListener("resultPicked", result.payload, dynamicElementName);
  }

  /**
   * Called when the user starts and ends an engagement with the urlbar.  For
   * details on parameters, see UrlbarProvider.onEngagement().
   *
   * @param {boolean} isPrivate
   *   True if the engagement is in a private context.
   * @param {string} state
   *   The state of the engagement, one of: start, engagement, abandonment,
   *   discard
   * @param {UrlbarQueryContext} queryContext
   *   The engagement's query context.  This is *not* guaranteed to be defined
   *   when `state` is "start".  It will always be defined for "engagement" and
   *   "abandonment".
   * @param {object} details
   *   This is defined only when `state` is "engagement" or "abandonment", and
   *   it describes the search string and picked result.
   */
  onEngagement(isPrivate, state, queryContext, details) {
    let { result, element } = details;
    // By design, the "resultPicked" extension event should not be fired when
    // the picked element has a URL.
    if (result?.providerName == this.name && !element?.dataset.url) {
      this.#pickResult(result, element);
    }

    this._notifyListener("engagement", isPrivate, state);
  }

  /**
   * Calls a listener function set by the extension API implementation, if any.
   *
   * @param {string} eventName
   *   The name of the listener to call (i.e., the name of the event to fire).
   * @param {*} args
   *   Arguments to the listener function.
   * @returns {*}
   *   The value returned by the listener function, if any.
   */
  async _notifyListener(eventName, ...args) {
    let listener = this._eventListeners.get(eventName);
    if (!listener) {
      return undefined;
    }
    let result;
    try {
      result = listener(...args);
    } catch (error) {
      this.logger.error(error);
      return undefined;
    }
    if (result.catch) {
      // The result is a promise, so wait for it to be resolved.  Set up a timer
      // so that we're not stuck waiting forever.
      let timer = new SkippableTimer({
        name: "UrlbarProviderExtension notification timer",
        time: lazy.UrlbarPrefs.get("extension.timeout"),
        reportErrorOnTimeout: true,
        logger: this.logger,
      });
      result = await Promise.race([
        timer.promise,
        result.catch(ex => this.logger.error(ex)),
      ]);
      timer.cancel();
    }
    return result;
  }

  /**
   * Converts a plain-JS-object result created by the extension into a
   * UrlbarResult object.
   *
   * @param {UrlbarQueryContext} context
   *   The query context.
   * @param {object} extResult
   *   A plain JS object representing a result created by the extension.
   * @returns {UrlbarResult}
   *   The UrlbarResult object.
   */
  async _makeUrlbarResult(context, extResult) {
    // If the result is a search result, make sure its payload has a valid
    // `engine` property, which is the name of an engine, and which we use later
    // on to look up the nsISearchEngine.  We allow the extension to specify the
    // engine by its name, alias, or domain.  Prefer aliases over domains since
    // one domain can have many engines.
    if (extResult.type == "search") {
      let engine;
      if (extResult.payload.engine) {
        // Validate the engine name by looking it up.
        engine = Services.search.getEngineByName(extResult.payload.engine);
      } else if (extResult.payload.keyword) {
        // Look up the engine by its alias.
        engine = await lazy.UrlbarSearchUtils.engineForAlias(
          extResult.payload.keyword
        );
      } else if (extResult.payload.url) {
        // Look up the engine by its domain.
        let host;
        try {
          host = new URL(extResult.payload.url).hostname;
        } catch (err) {}
        if (host) {
          engine = (
            await lazy.UrlbarSearchUtils.enginesForDomainPrefix(host)
          )[0];
        }
      }
      if (!engine) {
        // No engine found.
        throw new Error("Invalid or missing engine specified by extension");
      }
      extResult.payload.engine = engine.name;
    }

    let type = UrlbarProviderExtension.RESULT_TYPES[extResult.type];
    if (type == UrlbarUtils.RESULT_TYPE.TIP) {
      extResult.payload.type ||= "extension";
      extResult.payload.helpL10n = {
        id: lazy.UrlbarPrefs.get("resultMenu")
          ? "urlbar-result-menu-tip-get-help"
          : "urlbar-tip-help-icon",
      };
    }

    let result = new lazy.UrlbarResult(
      UrlbarProviderExtension.RESULT_TYPES[extResult.type],
      UrlbarProviderExtension.SOURCE_TYPES[extResult.source],
      ...lazy.UrlbarResult.payloadAndSimpleHighlights(
        context.tokens,
        extResult.payload || {}
      )
    );
    if (extResult.heuristic && this.behavior == "restricting") {
      // The muxer chooses the final heuristic result by taking the first one
      // that claims to be the heuristic.  We don't want extensions to clobber
      // the default heuristic, so we allow this only if the provider is
      // restricting.
      result.heuristic = extResult.heuristic;
    }
    if (extResult.suggestedIndex !== undefined) {
      result.suggestedIndex = extResult.suggestedIndex;
    }
    return result;
  }
}

// Maps extension result type enums to internal result types.
UrlbarProviderExtension.RESULT_TYPES = {
  dynamic: UrlbarUtils.RESULT_TYPE.DYNAMIC,
  keyword: UrlbarUtils.RESULT_TYPE.KEYWORD,
  omnibox: UrlbarUtils.RESULT_TYPE.OMNIBOX,
  remote_tab: UrlbarUtils.RESULT_TYPE.REMOTE_TAB,
  search: UrlbarUtils.RESULT_TYPE.SEARCH,
  tab: UrlbarUtils.RESULT_TYPE.TAB_SWITCH,
  tip: UrlbarUtils.RESULT_TYPE.TIP,
  url: UrlbarUtils.RESULT_TYPE.URL,
};

// Maps extension source type enums to internal source types.
UrlbarProviderExtension.SOURCE_TYPES = {
  bookmarks: UrlbarUtils.RESULT_SOURCE.BOOKMARKS,
  history: UrlbarUtils.RESULT_SOURCE.HISTORY,
  local: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
  network: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK,
  search: UrlbarUtils.RESULT_SOURCE.SEARCH,
  tabs: UrlbarUtils.RESULT_SOURCE.TABS,
  actions: UrlbarUtils.RESULT_SOURCE.ACTIONS,
};

/**
 * Returns a copy of a query context stripped of non-serializable properties.
 * This is necessary because query contexts are passed to extensions where they
 * become `Query` objects, as defined in the urlbar extensions schema. The
 * WebExtensions framework automatically excludes serializable properties that
 * aren't defined in the schema, but it chokes on non-serializable properties.
 *
 * @param {UrlbarQueryContext} context
 *   The query context.
 * @returns {object}
 *   A copy of `context` with only serializable properties.
 */
function makeSerializable(context) {
  return Object.fromEntries(
    Object.entries(context).filter(
      ([key]) => !NONSERIALIZABLE_CONTEXT_PROPERTIES.has(key)
    )
  );
}
