/* 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 lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  error: "chrome://remote/content/shared/messagehandler/Errors.sys.mjs",
  EventsDispatcher:
    "chrome://remote/content/shared/messagehandler/EventsDispatcher.sys.mjs",
  Log: "chrome://remote/content/shared/Log.sys.mjs",
  ModuleCache:
    "chrome://remote/content/shared/messagehandler/ModuleCache.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "logger", () => lazy.Log.get());

/**
 * A ContextDescriptor object provides information to decide if a broadcast or
 * a session data item should be applied to a specific MessageHandler context.
 *
 * @typedef {object} ContextDescriptor
 * @property {ContextDescriptorType} type
 *     The type of context
 * @property {string=} id
 *     Unique id of a given context for the provided type.
 *     For ContextDescriptorType.All, id can be omitted.
 *     For ContextDescriptorType.TopBrowsingContext, the id should be the
 *     browserId corresponding to a top-level browsing context.
 *     For ContextDescriptorType.UserContext, the id should be the
 *     platform user context id.
 */

/**
 * Enum of ContextDescriptor types.
 *
 * @enum {string}
 */
export const ContextDescriptorType = {
  All: "All",
  TopBrowsingContext: "TopBrowsingContext",
  UserContext: "UserContext",
};

/**
 * A ContextInfo identifies a given context that can be linked to a MessageHandler
 * instance. It should be used to identify events coming from this context.
 *
 * It can either be provided by the MessageHandler itself, when the event is
 * emitted from the context it relates to.
 *
 * Or it can be assembled manually, for instance when emitting an event which
 * relates to a window global from the root layer (eg browsingContext.contextCreated).
 *
 * @typedef {object} ContextInfo
 * @property {string} contextId
 *     Unique id of the MessageHandler corresponding to this context.
 * @property {string} type
 *     One of MessageHandler.type.
 */

/**
 * MessageHandler instances are dedicated to handle both Commands and Events
 * to enable automation and introspection for remote control protocols.
 *
 * MessageHandler instances are designed to form a network, where each instance
 * should allow to inspect a specific context (eg. a BrowsingContext, a Worker,
 * etc). Those instances might live in different processes and threads but
 * should be linked together by the usage of a single sessionId, shared by all
 * the instances of a single MessageHandler network.
 *
 * MessageHandler instances will be dynamically spawned depending on which
 * Command or which Event needs to be processed and should therefore not be
 * explicitly created by consumers, nor used directly.
 *
 * The only exception is the ROOT MessageHandler. This MessageHandler will be
 * the entry point to send commands to the rest of the network. It will also
 * emit all the relevant events captured by the network.
 *
 * However, even to create this ROOT MessageHandler, consumers should use the
 * RootMessageHandlerRegistry. This singleton will ensure that MessageHandler
 * instances are properly registered and can be retrieved based on a given
 * session id as well as some other context information.
 */
export class MessageHandler extends EventEmitter {
  #context;
  #contextId;
  #eventsDispatcher;
  #moduleCache;
  #registry;
  #sessionId;

  /**
   * Create a new MessageHandler instance.
   *
   * @param {string} sessionId
   *     ID of the session the handler is used for.
   * @param {object} context
   *     The context linked to this MessageHandler instance.
   * @param {MessageHandlerRegistry} registry
   *     The MessageHandlerRegistry which owns this MessageHandler instance.
   */
  constructor(sessionId, context, registry) {
    super();

    this.#moduleCache = new lazy.ModuleCache(this);

    this.#sessionId = sessionId;
    this.#context = context;
    this.#contextId = this.constructor.getIdFromContext(context);
    this.#eventsDispatcher = new lazy.EventsDispatcher(this);
    this.#registry = registry;
  }

  get context() {
    return this.#context;
  }

  get contextId() {
    return this.#contextId;
  }

  get eventsDispatcher() {
    return this.#eventsDispatcher;
  }

  get moduleCache() {
    return this.#moduleCache;
  }

  get name() {
    return [this.sessionId, this.constructor.type, this.contextId].join("-");
  }

  get registry() {
    return this.#registry;
  }

  get sessionId() {
    return this.#sessionId;
  }

  destroy() {
    lazy.logger.trace(
      `MessageHandler ${this.constructor.type} for session ${this.sessionId} is being destroyed`
    );
    this.#eventsDispatcher.destroy();
    this.#moduleCache.destroy();

    // At least the MessageHandlerRegistry will be expecting this event in order
    // to remove the instance from the registry when destroyed.
    this.emit("message-handler-destroyed", this);
  }

  /**
   * Check if any of the provided contexts match the provided contextDescriptor.
   *
   * @param {Array<BrowsingContext>} browsingContexts
   *     The browsing contexts to verify.
   * @param {ContextDescriptor} contextDescriptor
   *     The context descriptor to match.
   *
   * @returns {boolean}
   *     Return "true" if any context matches the context descriptor,
   *     "false" otherwise.
   */
  contextsMatchDescriptor(browsingContexts, contextDescriptor) {
    return (
      contextDescriptor.type === ContextDescriptorType.All ||
      (contextDescriptor.type === ContextDescriptorType.TopBrowsingContext &&
        browsingContexts.some(bc => contextDescriptor.id === bc.browserId)) ||
      (contextDescriptor.type === ContextDescriptorType.UserContext &&
        browsingContexts.some(
          bc => contextDescriptor.id === bc.originAttributes.userContextId
        ))
    );
  }

  /**
   * Emit a message handler event.
   *
   * Such events should bubble up to the root of a MessageHandler network.
   *
   * @param {string} name
   *     Name of the event. Protocol level events should be of the
   *     form [module name].[event name].
   * @param {object} data
   *     The event's data.
   * @param {Array<ContextInfo>=} relatedContexts
   *     The event's related contexts info, used to identify the navigables
   *     related to the event.
   *     If not provided, the context info of the current MessageHandler will be
   *     used as single related context info.
   */
  emitEvent(name, data, relatedContexts) {
    // If no relatedContexts field is provided on the event, extract it from the
    // MessageHandler instance.
    relatedContexts = relatedContexts || [this.#getContextInfo()];

    // Events are emitted both under their own name for consumers listening to
    // a specific and as `message-handler-event` for consumers which need to
    // catch all events.
    this.emit(name, data, relatedContexts);
    this.emit("message-handler-event", {
      name,
      relatedContexts,
      data,
      sessionId: this.sessionId,
    });
  }

  /**
   * @typedef {object} CommandDestination
   * @property {string} type
   *     One of MessageHandler.type.
   * @property {string | number=} id
   *     Unique context identifier. The format depends on the type.
   *     For WINDOW_GLOBAL destinations, this is a browsing context id.
   *     Optional, should only be provided if `contextDescriptor` is missing.
   * @property {ContextDescriptor=} contextDescriptor
   *     Descriptor used to match several contexts, which will all receive the
   *     command.
   *     Optional, should only be provided if `id` is missing.
   */

  /**
   * @typedef {object} Command
   * @property {string} commandName
   *     The name of the command to execute.
   * @property {string} moduleName
   *     The name of the module.
   * @property {object} params
   *     Optional command parameters.
   * @property {CommandDestination} destination
   *     The destination describing a debuggable context.
   * @property {boolean=} fromContentProcess
   *     Optional. Should be set on commands originating from content processes.
   * @property {boolean=} retryOnAbort
   *     Optional. When true, commands will be retried upon AbortError, which
   *     can occur when the underlying JSWindowActor pair is destroyed.
   *     If not explicitly set, the framework will automatically retry if the
   *     destination is likely to be replaced (e.g. browsingContext on the
   *     initial document or loading a document).
   * @property {boolean=} skipPrivilegeCheck
   *     Optional. When true, the command is allowed to be forwarded to a
   *     privileged browsing context even without system access. Defaults to
   *     false, which prevents the command from reaching a browsing context
   *     that became privileged after it was dispatched. Should only be set for
   *     commands that are safe regardless of the context's privilege level.
   */

  /**
   * Retrieve all module classes matching the moduleName and destination.
   * See `getAllModuleClasses` (ModuleCache.sys.mjs) for more details.
   *
   * @param {string} moduleName
   *     The name of the module.
   * @param {Destination} destination
   *     The destination.
   * @returns {Array.<class<Module>|null>}
   *     An array of Module classes.
   */
  getAllModuleClasses(moduleName, destination) {
    return this.#moduleCache.getAllModuleClasses(moduleName, destination);
  }

  /**
   * Handle a command, either in one of the modules owned by this MessageHandler
   * or in a another MessageHandler after forwarding the command.
   *
   * @param {Command} command
   *     The command that should be either handled in this layer or forwarded to
   *     the next layer leading to the destination.
   * @returns {Promise} A Promise that will resolve with the return value of the
   *     command once it has been executed.
   */
  handleCommand(command) {
    const { moduleName, commandName, fromContentProcess, params, destination } =
      command;
    lazy.logger.trace(
      `Received command ${moduleName}.${commandName} for destination ${destination.type}`
    );

    if (!this.supportsCommand(moduleName, commandName, destination)) {
      throw new lazy.error.UnsupportedCommandError(
        `${moduleName}.${commandName} not supported for destination ${destination?.type}`
      );
    }

    const module = this.#moduleCache.getModuleInstance(moduleName, destination);
    if (module && module.supportsMethod(commandName, fromContentProcess)) {
      return module[commandName](params, destination);
    }

    return this.forwardCommand(command);
  }

  toString() {
    return `[object ${this.constructor.name} ${this.name}]`;
  }

  /**
   * Execute the required initialization steps, including apply the initial session data items
   * provided to this MessageHandler on startup. Implementation is specific to each MessageHandler class.
   *
   * By default the implementation is a no-op.
   */
  async initialize() {}

  /**
   * Returns the module path corresponding to this MessageHandler class.
   *
   * Needs to be implemented in the sub class.
   */
  static get modulePath() {
    throw new Error("Not implemented");
  }

  /**
   * Returns the type corresponding to this MessageHandler class.
   *
   * Needs to be implemented in the sub class.
   */
  static get type() {
    throw new Error("Not implemented");
  }

  /**
   * Returns the id corresponding to a context compatible with this
   * MessageHandler class.
   *
   * Needs to be implemented in the sub class.
   */
  static getIdFromContext() {
    throw new Error("Not implemented");
  }

  /**
   * Forward a command to other MessageHandlers.
   *
   * Needs to be implemented in the sub class.
   */
  forwardCommand() {
    throw new Error("Not implemented");
  }

  /**
   * Check if contextDescriptor matches the context linked
   * to this MessageHandler instance.
   *
   * Needs to be implemented in the sub class.
   */
  matchesContext() {
    throw new Error("Not implemented");
  }

  /**
   * Check if the given command is supported in the module
   * for the destination
   *
   * @param {string} moduleName
   *     The name of the module.
   * @param {string} commandName
   *     The name of the command.
   * @param {Destination} destination
   *     The destination.
   * @returns {boolean}
   *     True if the command is supported.
   */
  supportsCommand(moduleName, commandName, destination) {
    return this.getAllModuleClasses(moduleName, destination).some(cls =>
      cls.supportsMethod(commandName)
    );
  }

  /**
   * Return the context information for this MessageHandler instance, which
   * can be used to identify the origin of an event.
   *
   * @returns {ContextInfo}
   *     The context information for this MessageHandler.
   */
  #getContextInfo() {
    return {
      contextId: this.contextId,
      type: this.constructor.type,
    };
  }
}
