/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";

const CATEGORY_NAME = "browser-newtab-external-component";

const lazy = {};

ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
  return console.createInstance({
    prefix: "AboutNewTabComponents",
    maxLogLevel: Services.prefs.getBoolPref(
      "browser.newtabpage.activity-stream.externalComponents.log",
      false
    )
      ? "Debug"
      : "Warn",
  });
});

/**
 * @typedef {object} NewTabComponentConfiguration
 * @property {string} type
 * @property {string[]} l10nURLs
 * @property {string} componentURL
 * @property {string} tagName
 */

/**
 * The AboutNewTabComponentRegistry is a class that manages a list of
 * external components registered to appear on the newtab page.
 *
 * The registry is an EventEmitter, and will emit the UPDATED_EVENT when the
 * registry changes.
 *
 * The registry is bootstrapped via entries in the nsICategoryManager of name
 * CATEGORY_NAME.
 */
class AboutNewTabComponentRegistry extends EventEmitter {
  static TYPES = Object.freeze({
    SEARCH: "SEARCH",
  });
  static UPDATED_EVENT = "updated";

  /**
   * The list of registered external component configurations, keyed on their
   * type.
   *
   * @type {Map<string, NewTabComponentConfiguration>}
   */
  #registeredComponents = new Map();

  /**
   * A mapping of external component registrant instances, keyed on their
   * module URI.
   *
   * @type {Map<string, BaseAboutNewTabComponentRegistrant>}
   */
  #registrants = new Map();

  constructor() {
    super();

    lazy.logConsole.debug("Instantiating AboutNewTabComponentRegistry");
    this.#infalliblyLoadConfigurations();

    Services.obs.addObserver(this, "xpcom-category-entry-removed");
    Services.obs.addObserver(this, "xpcom-category-entry-added");
    Services.obs.addObserver(this, "xpcom-category-cleared");
    Services.obs.addObserver(this, "profile-before-change");
  }

  observe(subject, topic, data) {
    switch (topic) {
      case "xpcom-category-entry-removed":
      // Intentional fall-through
      case "xpcom-category-entry-added":
      // Intentional fall-through
      case "xpcom-category-cleared": {
        // Intentional fall-through
        if (data === CATEGORY_NAME) {
          this.#infalliblyLoadConfigurations();
        }
        break;
      }
      case "profile-before-change": {
        this.destroy();
        break;
      }
    }
  }

  destroy() {
    for (let registrant of this.#registrants.values()) {
      registrant.destroy();
    }
    this.#registrants.clear();
    this.#registeredComponents.clear();

    Services.obs.removeObserver(this, "xpcom-category-entry-removed");
    Services.obs.removeObserver(this, "xpcom-category-entry-added");
    Services.obs.removeObserver(this, "xpcom-category-cleared");
    Services.obs.removeObserver(this, "profile-before-change");
  }

  /**
   * Iterates the CATEGORY_NAME nsICategoryManager category, and attempts to
   * load each registrant's configuration, which updates the
   * #registeredComponents. Updating the #registeredComponents will cause the
   * UPDATED_EVENT event to be emitted from this class.
   *
   * Invalid configurations are skipped.
   *
   * This method will log errors but is guaranteed to return, even if one or
   * more of the configurations is invalid.
   */
  #infalliblyLoadConfigurations() {
    lazy.logConsole.debug("Loading configurations");
    this.#registeredComponents.clear();

    for (let { entry, value } of Services.catMan.enumerateCategory(
      CATEGORY_NAME
    )) {
      try {
        lazy.logConsole.debug("Loading ", entry, value);
        let registrar = null;
        if (this.#registrants.has(entry)) {
          lazy.logConsole.debug("Found pre-existing registrant for ", entry);
          registrar = this.#registrants.get(entry);
        } else {
          lazy.logConsole.debug("Constructing registrant for ", entry);
          const module = ChromeUtils.importESModule(entry);
          const registrarClass = module[value];

          if (
            !(
              registrarClass.prototype instanceof
              BaseAboutNewTabComponentRegistrant
            )
          ) {
            throw new Error(
              `Registrant for ${entry} does not subclass BaseAboutNewTabComponentRegistrant`
            );
          }

          registrar = new registrarClass();
          this.#registrants.set(entry, registrar);
          registrar.on(AboutNewTabComponentRegistry.UPDATED_EVENT, () => {
            this.#infalliblyLoadConfigurations();
          });
        }

        let configurations = registrar.getComponents();
        for (let configuration of configurations) {
          if (this.#validateConfiguration(configuration)) {
            lazy.logConsole.debug(
              `Validated a configuration for type ${configuration.type}`
            );
            this.#registeredComponents.set(configuration.type, configuration);
          } else {
            lazy.logConsole.error(
              `Failed to validate a configuration:`,
              configuration
            );
          }
        }
      } catch (e) {
        lazy.logConsole.error(
          "Failed to load configurations ",
          entry,
          value,
          e.message
        );
      }
    }

    this.emit(AboutNewTabComponentRegistry.UPDATED_EVENT);
  }

  /**
   * Ensures that the configuration abides by newtab's external component
   * rules. Currently, that just means that two components cannot share the
   * same type.
   *
   * @param {NewTabComponentConfiguration} configuration
   * @returns {boolean}
   */
  #validateConfiguration(configuration) {
    if (!configuration.type) {
      return false;
    }

    // Currently, the only validation is to ensure that something isn't already
    // registered with the same type. This rule might evolve over time if we
    // start allowing multiples of a type.
    if (this.#registeredComponents.has(configuration.type)) {
      return false;
    }

    return true;
  }

  /**
   * Returns a copy of the configuration registry for external consumption.
   *
   * @returns {NewTabComponentConfiguration[]}
   */
  get values() {
    return Array.from(this.#registeredComponents.values());
  }
}

/**
 * Any registrants that want to register an external component onto newtab must
 * subclass this base class in order to provide the configuration for their
 * component. They must then add their registrant to the nsICategoryManager
 * category `browser-newtab-external-component`, where the entry is the URI
 * for the module containing the subclass, and the value is the name of the
 * subclass exported by the module.
 */
class BaseAboutNewTabComponentRegistrant extends EventEmitter {
  /**
   * Subclasses can override this method to do any cleanup when the component
   * registry starts being shut down.
   */
  destroy() {}

  /**
   * Subclasses should override this method to provide one or more
   * NewTabComponentConfiguration's.
   *
   * @returns {NewTabComponentConfiguration[]}
   */
  getComponents() {
    return [];
  }

  /**
   * Subclasses can call this method if their component registry ever needs
   * updating. This will alert the registry to update itself.
   */
  updated() {
    this.emit(AboutNewTabComponentRegistry.UPDATED_EVENT);
  }
}

export { AboutNewTabComponentRegistry, BaseAboutNewTabComponentRegistrant };
