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

"use strict";

const {
  TYPES: { CONSOLE_MESSAGE },
} = require("devtools/server/actors/resources/index");
const Targets = require("devtools/server/actors/targets/index");

const consoleAPIListenerModule = isWorker
  ? "devtools/server/actors/webconsole/worker-listeners"
  : "devtools/server/actors/webconsole/listeners/console-api";
const { ConsoleAPIListener } = require(consoleAPIListenerModule);

const { isArray } = require("devtools/server/actors/object/utils");

const {
  makeDebuggeeValue,
  createValueGripForTarget,
} = require("devtools/server/actors/object/utils");

const {
  getActorIdForInternalSourceId,
} = require("devtools/server/actors/utils/dbg-source");

const {
  isSupportedByConsoleTable,
} = require("devtools/shared/webconsole/messages");

/**
 * Start watching for all console messages related to a given Target Actor.
 * This will notify about existing console messages, but also the one created in future.
 *
 * @param TargetActor targetActor
 *        The target actor from which we should observe console messages
 * @param Object options
 *        Dictionary object with following attributes:
 *        - onAvailable: mandatory function
 *          This will be called for each resource.
 */
class ConsoleMessageWatcher {
  async watch(targetActor, { onAvailable }) {
    this.targetActor = targetActor;
    this.onAvailable = onAvailable;

    // Bug 1642297: Maybe we could merge ConsoleAPI Listener into this module?
    const onConsoleAPICall = message => {
      onAvailable([
        {
          resourceType: CONSOLE_MESSAGE,
          message: prepareConsoleMessageForRemote(targetActor, message),
        },
      ]);
    };

    const isTargetActorContentProcess =
      targetActor.targetType === Targets.TYPES.PROCESS;

    // Only consider messages from a given window for all FRAME targets (this includes
    // WebExt and ParentProcess which inherits from WindowGlobalTargetActor)
    // But ParentProcess should be ignored as we want all messages emitted directly from
    // that process (window and window-less).
    // To do that we pass a null window and ConsoleAPIListener will catch everything.
    // And also ignore WebExtension as we will filter out only by addonId, which is
    // passed via consoleAPIListenerOptions. WebExtension may have multiple windows/documents
    // but all of them will be flagged with the same addon ID.
    const messagesShouldMatchWindow =
      targetActor.targetType === Targets.TYPES.FRAME &&
      targetActor.typeName != "parentProcessTarget" &&
      targetActor.typeName != "webExtensionTarget";
    const window = messagesShouldMatchWindow ? targetActor.window : null;

    // If we should match messages for a given window but for some reason, targetActor.window
    // did not return a window, bail out. Otherwise we wouldn't have anything to match against
    // and would consume all the messages, which could lead to issue (e.g. infinite loop,
    // see Bug 1828026).
    if (messagesShouldMatchWindow && !window) {
      return;
    }

    const listener = new ConsoleAPIListener(window, onConsoleAPICall, {
      excludeMessagesBoundToWindow: isTargetActorContentProcess,
      matchExactWindow: targetActor.ignoreSubFrames,
      ...(targetActor.consoleAPIListenerOptions || {}),
    });
    this.listener = listener;
    listener.init();

    // It can happen that the targetActor does not have a window reference (e.g. in worker
    // thread, targetActor exposes a workerGlobal property)
    const winStartTime =
      targetActor.window?.performance?.timing?.navigationStart || 0;

    const cachedMessages = listener.getCachedMessages(!targetActor.isRootActor);
    const messages = [];
    // Filter out messages that came from a ServiceWorker but happened
    // before the page was requested.
    for (const message of cachedMessages) {
      if (
        message.innerID === "ServiceWorker" &&
        winStartTime > message.timeStamp
      ) {
        continue;
      }
      messages.push({
        resourceType: CONSOLE_MESSAGE,
        message: prepareConsoleMessageForRemote(targetActor, message),
      });
    }
    onAvailable(messages);
  }

  /**
   * Stop watching for console messages.
   */
  destroy() {
    if (this.listener) {
      this.listener.destroy();
      this.listener = null;
    }
    this.targetActor = null;
    this.onAvailable = null;
  }

  /**
   * Spawn some custom console messages.
   * This is used for example for log points and JS tracing.
   *
   * @param Array<Object> messages
   *        A list of fake nsIConsoleMessage, which looks like the one being generated by
   *        the platform API.
   */
  emitMessages(messages) {
    if (!this.listener) {
      throw new Error("This target actor isn't listening to console messages");
    }
    this.onAvailable(
      messages.map(message => {
        if (!message.timeStamp) {
          throw new Error("timeStamp property is mandatory");
        }

        return {
          resourceType: CONSOLE_MESSAGE,
          message: prepareConsoleMessageForRemote(this.targetActor, message),
        };
      })
    );
  }
}

module.exports = ConsoleMessageWatcher;

/**
 * Return the properties needed to display the appropriate table for a given
 * console.table call.
 * This function does a little more than creating an ObjectActor for the first
 * parameter of the message. When layout out the console table in the output, we want
 * to be able to look into sub-properties so the table can have a different layout (
 * for arrays of arrays, objects with objects properties, arrays of objects, …).
 * So here we need to retrieve the properties of the first parameter, and also all the
 * sub-properties we might need.
 *
 * @param {TargetActor} targetActor: The Target Actor from which this object originates.
 * @param {Object} result: The console.table message.
 * @returns {Object} An object containing the properties of the first argument of the
 *                   console.table call.
 */
function getConsoleTableMessageItems(targetActor, result) {
  const [tableItemGrip] = result.arguments;
  const dataType = tableItemGrip.class;
  const needEntries = ["Map", "WeakMap", "Set", "WeakSet"].includes(dataType);
  const ignoreNonIndexedProperties = isArray(tableItemGrip);

  const tableItemActor = targetActor.getActorByID(tableItemGrip.actor);
  if (!tableItemActor) {
    return null;
  }

  // Retrieve the properties (or entries for Set/Map) of the console table first arg.
  const iterator = needEntries
    ? tableItemActor.enumEntries()
    : tableItemActor.enumProperties({
        ignoreNonIndexedProperties,
      });
  const { ownProperties } = iterator.all();

  // The iterator returns a descriptor for each property, wherein the value could be
  // in one of those sub-property.
  const descriptorKeys = ["safeGetterValues", "getterValue", "value"];

  Object.values(ownProperties).forEach(desc => {
    if (typeof desc !== "undefined") {
      descriptorKeys.forEach(key => {
        if (desc && desc.hasOwnProperty(key)) {
          const grip = desc[key];

          // We need to load sub-properties as well to render the table in a nice way.
          const actor = grip && targetActor.getActorByID(grip.actor);
          if (actor) {
            const res = actor
              .enumProperties({
                ignoreNonIndexedProperties: isArray(grip),
              })
              .all();
            if (res?.ownProperties) {
              desc[key].ownProperties = res.ownProperties;
            }
          }
        }
      });
    }
  });

  return ownProperties;
}

/**
 * Prepare a message from the console API to be sent to the remote Web Console
 * instance.
 *
 * @param TargetActor targetActor
 *        The related target actor
 * @param object message
 *        The original message received from the console storage listener.
 * @return object
 *         The object that can be sent to the remote client.
 */
function prepareConsoleMessageForRemote(targetActor, message) {
  const result = {
    arguments: message.arguments
      ? message.arguments.map(obj => {
          const dbgObj = makeDebuggeeValue(targetActor, obj);
          return createValueGripForTarget(targetActor, dbgObj);
        })
      : [],
    columnNumber: message.columnNumber,
    filename: message.filename,
    level: message.level,
    lineNumber: message.lineNumber,
    // messages emitted from Console.sys.mjs don't have a microSecondTimeStamp property
    timeStamp: message.microSecondTimeStamp
      ? message.microSecondTimeStamp / 1000
      : message.timeStamp || ChromeUtils.dateNow(),
    sourceId: getActorIdForInternalSourceId(targetActor, message.sourceId),
    innerWindowID: message.innerID,
  };

  // This can be a hot path when loading lots of messages, and it only make sense to
  // include the following properties in the message when they have a meaningful value.
  // Otherwise we simply don't include them so we save cycles in JSActor communication.
  if (message.chromeContext) {
    result.chromeContext = message.chromeContext;
  }

  if (message.counter) {
    result.counter = message.counter;
  }
  if (message.private) {
    result.private = message.private;
  }
  if (message.prefix) {
    result.prefix = message.prefix;
  }

  if (message.stacktrace) {
    result.stacktrace = message.stacktrace.map(frame => {
      return {
        ...frame,
        sourceId: getActorIdForInternalSourceId(targetActor, frame.sourceId),
      };
    });
  }

  if (message.styles && message.styles.length) {
    result.styles = message.styles.map(string => {
      return createValueGripForTarget(targetActor, string);
    });
  }

  if (message.timer) {
    result.timer = message.timer;
  }

  if (message.level === "table") {
    if (result && isSupportedByConsoleTable(result.arguments)) {
      const tableItems = getConsoleTableMessageItems(targetActor, result);
      if (tableItems) {
        result.arguments[0].ownProperties = tableItems;
        result.arguments[0].preview = null;

        // Only return the 2 first params.
        result.arguments = result.arguments.slice(0, 2);
      }
    }
    // NOTE: See transformConsoleAPICallResource for not-supported case.
  }

  return result;
}
