/* 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 Debugger = require("Debugger");
const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");

const lazy = {};
if (!isWorker) {
  ChromeUtils.defineESModuleGetters(
    lazy,
    {
      Reflect: "resource://gre/modules/reflect.sys.mjs",
    },
    { global: "contextual" }
  );
}
loader.lazyRequireGetter(
  this,
  ["isCommand"],
  "resource://devtools/server/actors/webconsole/commands/parser.js",
  true
);
loader.lazyRequireGetter(
  this,
  "WebConsoleCommandsManager",
  "resource://devtools/server/actors/webconsole/commands/manager.js",
  true
);

loader.lazyRequireGetter(
  this,
  "LongStringActor",
  "resource://devtools/server/actors/string.js",
  true
);
loader.lazyRequireGetter(
  this,
  "eagerEcmaAllowlist",
  "resource://devtools/server/actors/webconsole/eager-ecma-allowlist.js"
);
loader.lazyRequireGetter(
  this,
  "eagerFunctionAllowlist",
  "resource://devtools/server/actors/webconsole/eager-function-allowlist.js"
);

function isObject(value) {
  return Object(value) === value;
}

/**
 * Evaluates a string using the debugger API.
 *
 * To allow the variables view to update properties from the Web Console we
 * provide the "selectedObjectActor" mechanism: the Web Console tells the
 * ObjectActor ID for which it desires to evaluate an expression. The
 * Debugger.Object pointed at by the actor ID is bound such that it is
 * available during expression evaluation (executeInGlobalWithBindings()).
 *
 * Example:
 *   _self['foobar'] = 'test'
 * where |_self| refers to the desired object.
 *
 * The |frameActor| property allows the Web Console client to provide the
 * frame actor ID, such that the expression can be evaluated in the
 * user-selected stack frame.
 *
 * For the above to work we need the debugger and the Web Console to share
 * a connection, otherwise the Web Console actor will not find the frame
 * actor.
 *
 * The Debugger.Frame comes from the jsdebugger's Debugger instance, which
 * is different from the Web Console's Debugger instance. This means that
 * for evaluation to work, we need to create a new instance for the Web
 * Console Commands helpers - they need to be Debugger.Objects coming from the
 * jsdebugger's Debugger instance.
 *
 * When |selectedObjectActor| is used objects can come from different iframes,
 * from different domains. To avoid permission-related errors when objects
 * come from a different window, we also determine the object's own global,
 * such that evaluation happens in the context of that global. This means that
 * evaluation will happen in the object's iframe, rather than the top level
 * window.
 *
 * @param string string
 *        String to evaluate.
 * @param object [options]
 *        Options for evaluation:
 *        - selectedObjectActor: the ObjectActor ID to use for evaluation.
 *          |evalWithBindings()| will be called with one additional binding:
 *          |_self| which will point to the Debugger.Object of the given
 *          ObjectActor. Executes with the top level window as the global.
 *        - frameActor: the FrameActor ID to use for evaluation. The given
 *        debugger frame is used for evaluation, instead of the global window.
 *        - selectedNodeActor: the NodeActor ID of the currently selected node
 *        in the Inspector (or null, if there is no selection). This is used
 *        for helper functions that make reference to the currently selected
 *        node, like $0.
 *        - innerWindowID: An optional window id to use instead of webConsole.evalWindow.
 *        This is used by function that need to evaluate in a different window for which
 *        we don't have a dedicated target (for example a non-remote iframe).
 *        - eager: Set to true if you want the evaluation to bail if it may have side effects.
 *        - url: the url to evaluate the script as. Defaults to "debugger eval code",
 *        or "debugger eager eval code" if eager is true.
 *        - preferConsoleCommandsOverLocalSymbols: Set to true if console commands
 *        should override local symbols.
 * @param object webConsole
 *
 * @return object
 *         An object that holds the following properties:
 *         - dbg: the debugger where the string was evaluated.
 *         - frame: (optional) the frame where the string was evaluated.
 *         - global: the Debugger.Object for the global where the string was evaluated in.
 *         - result: the result of the evaluation.
 */
function evalWithDebugger(string, options = {}, webConsole) {
  const trimmedString = string.trim();
  // The help function needs to be easy to guess, so accept "?" as a shortcut
  if (trimmedString === "?") {
    return evalWithDebugger(":help", options, webConsole);
  }

  const isCmd = isCommand(trimmedString);

  if (isCmd && options.eager) {
    return {
      result: null,
    };
  }

  const { frame, dbg } = getFrameDbg(options, webConsole);

  const { dbgGlobal, bindSelf } = getDbgGlobal(options, dbg, webConsole);

  // If the strings starts with a `:`, do not try to evaluate the strings
  // and instead only call the related command function directly from
  // the privileged codebase.
  if (isCmd) {
    try {
      return WebConsoleCommandsManager.executeCommand(
        webConsole,
        dbgGlobal,
        options.selectedNodeActor,
        string
      );
    } catch (e) {
      // Catch any exception and return a result similar to the output
      // of executeCommand to notify the client about this unexpected error.
      return {
        helperResult: {
          type: "exception",
          message: e.message,
        },
      };
    }
  }

  const helpers = WebConsoleCommandsManager.getWebConsoleCommands(
    webConsole,
    dbgGlobal,
    frame,
    string,
    options.selectedNodeActor,
    options.preferConsoleCommandsOverLocalSymbols
  );
  let { bindings } = helpers;

  // Ease calling the help command by not requiring the "()".
  // But wait for the bindings computation in order to know if "help" variable
  // was overloaded by the page. If it is missing from bindings, it is overloaded and we should
  // display its value by doing a regular evaluation.
  if (trimmedString === "help" && bindings.help) {
    return evalWithDebugger(":help", options, webConsole);
  }

  // '_self' refers to the JS object references via options.selectedObjectActor.
  // This isn't exposed on typical console evaluation, but only when "Store As Global"
  // runs an invisible script storing `_self` into `temp${i}`.
  if (bindSelf) {
    bindings._self = bindSelf;
  }

  // Log points calls this method from the server side and pass additional variables
  // to be exposed to the evaluated JS string
  if (options.bindings) {
    bindings = { ...bindings, ...options.bindings };
  }

  const evalOptions = {};

  const urlOption =
    options.url || (options.eager ? "debugger eager eval code" : null);
  if (typeof urlOption === "string") {
    evalOptions.url = urlOption;
  }

  if (typeof options.lineNumber === "number") {
    evalOptions.lineNumber = options.lineNumber;
  }

  if (options.disableBreaks || options.eager) {
    // When we are disabling breakpoints for a given evaluation, or when we are doing an eager evaluation,
    // also prevent spawning related Debugger.Source object to avoid showing it
    // in the debugger UI
    evalOptions.hideFromDebugger = true;
  }

  if (options.preferConsoleCommandsOverLocalSymbols) {
    evalOptions.useInnerBindings = true;
  }

  updateConsoleInputEvaluation(dbg, webConsole);

  const evalString = getEvalInput(string, bindings);
  const result = getEvalResult(
    dbg,
    evalString,
    evalOptions,
    bindings,
    frame,
    dbgGlobal,
    options.eager
  );

  // Attempt to initialize any declarations found in the evaluated string
  // since they may now be stuck in an "initializing" state due to the
  // error. Already-initialized bindings will be ignored.
  if (!frame && result && "throw" in result) {
    forceLexicalInitForVariableDeclarationsInThrowingExpression(
      dbgGlobal,
      string
    );
  }

  return {
    result,
    // Retrieve the result of commands, if any ran
    helperResult: helpers.getHelperResult(),
    dbg,
    frame,
    dbgGlobal,
  };
}
exports.evalWithDebugger = evalWithDebugger;

/**
 * Sub-function to reduce the complexity of evalWithDebugger.
 * This focuses on calling Debugger.Frame or Debugger.Object eval methods.
 *
 * @param {Debugger} dbg
 * @param {string} string
 *        The string to evaluate.
 * @param {object} evalOptions
 *        Spidermonkey options to pass to eval methods.
 * @param {object} bindings
 *        Dictionary object with symbols to override in the evaluation.
 * @param {Debugger.Frame} frame
 *        If paused, the paused frame.
 * @param {Debugger.Object} dbgGlobal
 *        The target's global.
 * @param {boolean} eager
 *        Is this an eager evaluation?
 * @return {object}
 *        The evaluation result object.
 *        See `Debugger.Ojbect.executeInGlobalWithBindings` definition.
 */
function getEvalResult(
  dbg,
  string,
  evalOptions,
  bindings,
  frame,
  dbgGlobal,
  eager
) {
  // When we are doing an eager evaluation, we aren't using the target's Debugger object
  // but a special one, dedicated to each evaluation.
  let noSideEffectDebugger = null;
  if (eager) {
    noSideEffectDebugger = makeSideeffectFreeDebugger(dbg);

    // When a sideeffect-free debugger has been created, we need to eval
    // in the context of that debugger in order for the side-effect tracking
    // to apply.
    if (frame) {
      frame = noSideEffectDebugger.adoptFrame(frame);
    } else {
      dbgGlobal = noSideEffectDebugger.adoptDebuggeeValue(dbgGlobal);
    }
    if (bindings) {
      bindings = Object.keys(bindings).reduce((acc, key) => {
        acc[key] = noSideEffectDebugger.adoptDebuggeeValue(bindings[key]);
        return acc;
      }, {});
    }
  }

  try {
    let result;
    if (frame) {
      result = frame.evalWithBindings(string, bindings, evalOptions);
    } else {
      result = dbgGlobal.executeInGlobalWithBindings(
        string,
        bindings,
        evalOptions
      );
    }
    if (noSideEffectDebugger && result) {
      if ("return" in result) {
        result.return = dbg.adoptDebuggeeValue(result.return);
      }
      if ("throw" in result) {
        result.throw = dbg.adoptDebuggeeValue(result.throw);
      }
    }
    return result;
  } finally {
    // We need to be absolutely sure that the sideeffect-free debugger's
    // debuggees are removed because otherwise we risk them terminating
    // execution of later code in the case of unexpected exceptions.
    if (noSideEffectDebugger) {
      noSideEffectDebugger.onNativeCall = undefined;
      noSideEffectDebugger.shouldAvoidSideEffects = false;
      // Ensure removing the debuggee only as the very last step as various
      // cleanups within the Debugger API are done per still-registered debuggee.
      noSideEffectDebugger.removeAllDebuggees();
    }
  }
}

/**
 * Force lexical initialization for let/const variables declared in a throwing expression.
 * By spec, a lexical declaration is added to the *page-visible* global lexical environment
 * for those variables, meaning they can't be redeclared (See Bug 1246215).
 *
 * This function gets the AST of the throwing expression to collect all the let/const
 * declarations and call `forceLexicalInitializationByName`, which will initialize them
 * to undefined, making it possible for them to be redeclared.
 *
 * @param {DebuggerObject} dbgGlobal
 * @param {string} string: The expression that was evaluated and threw
 * @returns
 */
function forceLexicalInitForVariableDeclarationsInThrowingExpression(
  dbgGlobal,
  string
) {
  // Reflect is not usable in workers, so return early to avoid logging an error
  // to the console when loading it.
  if (isWorker) {
    return;
  }

  let ast;
  // Parse errors will raise an exception. We can/should ignore the error
  // since it's already being handled elsewhere and we are only interested
  // in initializing bindings.
  try {
    ast = lazy.Reflect.parse(string);
  } catch (e) {
    return;
  }

  try {
    for (const line of ast.body) {
      // Only let and const declarations put bindings into an
      // "initializing" state.
      if (!(line.kind == "let" || line.kind == "const")) {
        continue;
      }

      const identifiers = [];
      for (const decl of line.declarations) {
        switch (decl.id.type) {
          case "Identifier":
            // let foo = bar;
            identifiers.push(decl.id.name);
            break;
          case "ArrayPattern":
            // let [foo, bar]    = [1, 2];
            // let [foo=99, bar] = [1, 2];
            for (const e of decl.id.elements) {
              if (e.type == "Identifier") {
                identifiers.push(e.name);
              } else if (e.type == "AssignmentExpression") {
                identifiers.push(e.left.name);
              }
            }
            break;
          case "ObjectPattern":
            // let {bilbo, my}    = {bilbo: "baggins", my: "precious"};
            // let {blah: foo}    = {blah: yabba()}
            // let {blah: foo=99} = {blah: yabba()}
            for (const prop of decl.id.properties) {
              // key
              if (prop.key?.type == "Identifier") {
                identifiers.push(prop.key.name);
              }
              // value
              if (prop.value?.type == "Identifier") {
                identifiers.push(prop.value.name);
              } else if (prop.value?.type == "AssignmentExpression") {
                identifiers.push(prop.value.left.name);
              } else if (prop.type === "SpreadExpression") {
                identifiers.push(prop.expression.name);
              }
            }
            break;
        }
      }

      for (const name of identifiers) {
        dbgGlobal.forceLexicalInitializationByName(name);
      }
    }
  } catch (ex) {
    console.error(
      "Error in forceLexicalInitForVariableDeclarationsInThrowingExpression:",
      ex
    );
  }
}

/**
 * Creates a side-effect-free Debugger instance.
 *
 * @param {Debugger} targetActorDbg
 *        The target actor's dbg object, crafted by make-debugger.js module.
 * @return {Debugger}
 *         Side-effect-free Debugger instance.
 */
function makeSideeffectFreeDebugger(targetActorDbg) {
  // Populate the cached Map once before the evaluation
  ensureSideEffectFreeNatives();

  // Note: It is critical for debuggee performance that we implement all of
  // this debuggee tracking logic with a separate Debugger instance.
  // Bug 1617666 arises otherwise if we set an onEnterFrame hook on the
  // existing debugger object and then later clear it.
  //
  // Also note that we aren't registering any global to this debugger.
  // We will only adopt values into it: the paused frame (if any) or the
  // target's global (when not paused).
  const dbg = new Debugger();

  // Special flag in order to ensure that any evaluation or call being
  // made via this debugger will be ignored by all debuggers except this one.
  dbg.exclusiveDebuggerOnEval = true;

  // We need to register all JS globals that the evaluation may use.
  // By default WindowGlobalTarget (with "EFT" mode enabled by default) is specific to
  // only one JS global, related to the WindowGlobal it relates to.
  // But there is two edgecases:
  //  - the browser toolbox WindowGlobalTarget still have EFT turned off and each target
  //    may involve many WindowGlobal and so many JS globals.
  //  - if the current target's document has some iframes (or is an iframe) running in the
  //    same process, the evaluation may use `globalThis.contentWindow` or
  //   `iframeElement.(top|parent)` and so involve any of these same-process JS globals.
  //
  // While we don't have to do this for regular evaluations, it is important for
  // side-effect-free one as onNativeCall is only called for these registered globals.
  // If we miss one, we would prevent detecting side-effect calls in the missed global.
  const globals = targetActorDbg.findDebuggees(true);
  for (const global of globals) {
    try {
      dbg.addDebuggee(global);
    } catch (e) {
      // Ignore exceptions from the following cases:
      //   * A global from the same compartment (happens with parent process)
      //   * A dead wrapper (happens when the reference gets nuked after
      //     findAllGlobals call)
      if (
        !e.message.includes(
          "debugger and debuggee must be in different compartments"
        ) &&
        !e.message.includes("can't access dead object")
      ) {
        throw e;
      }
    }
  }

  const timeoutDuration = 100;
  const endTime = Date.now() + timeoutDuration;
  let count = 0;
  function shouldCancel() {
    // To keep the evaled code as quick as possible, we avoid querying the
    // current time on ever single step and instead check every 100 steps
    // as an arbitrary count that seemed to be "often enough".
    return ++count % 100 === 0 && Date.now() > endTime;
  }

  const executedScripts = new Set();
  const handler = {
    hit: () => null,
  };
  dbg.onEnterFrame = frame => {
    if (shouldCancel()) {
      return null;
    }
    frame.onStep = () => {
      if (shouldCancel()) {
        return null;
      }
      return undefined;
    };

    const script = frame.script;

    if (executedScripts.has(script)) {
      return undefined;
    }
    executedScripts.add(script);

    const offsets = script.getEffectfulOffsets();
    for (const offset of offsets) {
      script.setBreakpoint(offset, handler);
    }

    return undefined;
  };

  // The debugger only calls onNativeCall handlers on the debugger that is
  // explicitly calling either eval, DebuggerObject.apply or DebuggerObject.call,
  // so we need to add this hook on "dbg" even though the rest of our hooks work via "newDbg".
  const { SIDE_EFFECT_FREE } = WebConsoleCommandsManager;
  dbg.onNativeCall = (callee, reason) => {
    try {
      // Setters are always effectful. Natives called normally or called via
      // getters are handled with an allowlist.
      if (
        (reason == "get" || reason == "call") &&
        nativeIsEagerlyEvaluateable(callee)
      ) {
        // Returning undefined causes execution to continue normally.
        return undefined;
      }
    } catch (err) {
      DevToolsUtils.reportException(
        "evalWithDebugger onNativeCall",
        new Error("Unable to validate native function against allowlist")
      );
    }

    // The WebConsole Commands manager will use Cu.exportFunction which will force
    // to call a native method which is hard to identify.
    // getEvalResult will flag those getter methods with a magic attribute.
    if (
      reason == "call" &&
      callee.unsafeDereference().isSideEffectFree === SIDE_EFFECT_FREE
    ) {
      // Returning undefined causes execution to continue normally.
      return undefined;
    }

    // Returning null terminates the current evaluation.
    return null;
  };
  dbg.shouldAvoidSideEffects = true;

  return dbg;
}

// Native functions which are considered to be side effect free.
let gSideEffectFreeNatives; // string => Array(Function)

/**
 * Generate gSideEffectFreeNatives map.
 */
function ensureSideEffectFreeNatives() {
  if (gSideEffectFreeNatives) {
    return;
  }

  const { natives: domNatives } = eagerFunctionAllowlist;

  const natives = [
    ...eagerEcmaAllowlist.functions,
    ...eagerEcmaAllowlist.getters,

    // Pull in all of the non-ECMAScript native functions that we want to
    // allow as well.
    ...domNatives,
  ];

  const map = new Map();
  for (const n of natives) {
    if (!map.has(n.name)) {
      map.set(n.name, []);
    }
    map.get(n.name).push(n);
  }

  gSideEffectFreeNatives = map;
}

function nativeIsEagerlyEvaluateable(fn) {
  if (fn.isBoundFunction) {
    fn = fn.boundTargetFunction;
  }

  // We assume all DOM getters have no major side effect, and they are
  // eagerly-evaluateable.
  //
  // JitInfo is used only by methods/accessors in WebIDL, and being
  // "a getter with JitInfo" can be used as a condition to check if given
  // function is DOM getter.
  //
  // This includes privileged interfaces in addition to standard web APIs.
  if (fn.isNativeGetterWithJitInfo()) {
    return true;
  }

  // Natives with certain names are always considered side effect free.
  switch (fn.name) {
    case "toString":
    case "toLocaleString":
    case "valueOf":
      return true;
  }

  // This needs to use isSameNativeWithJitInfo instead of isSameNative, given
  // DOM methods share single native function with different JSJitInto,
  // and isSameNative cannot distinguish between side-effect-free methods
  // and others.
  const natives = gSideEffectFreeNatives.get(fn.name);
  return natives && natives.some(n => fn.isSameNativeWithJitInfo(n));
}

function updateConsoleInputEvaluation(dbg, webConsole) {
  // Adopt webConsole._lastConsoleInputEvaluation value in the new debugger,
  // to prevent "Debugger.Object belongs to a different Debugger" exceptions
  // related to the $_ bindings if the debugger object is changed from the
  // last evaluation.
  if (webConsole._lastConsoleInputEvaluation) {
    webConsole._lastConsoleInputEvaluation = dbg.adoptDebuggeeValue(
      webConsole._lastConsoleInputEvaluation
    );
  }
}

function getEvalInput(string) {
  const trimmedString = string.trim();
  // Add easter egg for console.mihai().
  if (
    trimmedString == "console.mihai()" ||
    trimmedString == "console.mihai();"
  ) {
    return '"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/"';
  }
  return string;
}

function getFrameDbg(options, webConsole) {
  if (!options.frameActor) {
    return { frame: null, dbg: webConsole.dbg };
  }
  // Find the Debugger.Frame of the given FrameActor.
  const frameActor = webConsole.conn.getActor(options.frameActor);
  if (frameActor) {
    // If we've been given a frame actor in whose scope we should evaluate the
    // expression, be sure to use that frame's Debugger (that is, the JavaScript
    // debugger's Debugger) for the whole operation, not the console's Debugger.
    // (One Debugger will treat a different Debugger's Debugger.Object instances
    // as ordinary objects, not as references to be followed, so mixing
    // debuggers causes strange behaviors.)
    return { frame: frameActor.frame, dbg: frameActor.threadActor.dbg };
  }
  return DevToolsUtils.reportException(
    "evalWithDebugger",
    Error("The frame actor was not found: " + options.frameActor)
  );
}

/**
 * Get debugger object for given debugger and Web Console.
 *
 * @param object options
 *        See the `options` parameter of evalWithDebugger
 * @param {Debugger} dbg
 *        Debugger object
 * @param {WebConsoleActor} webConsole
 *        A reference to a webconsole actor which is used to get the target
 *        eval global and optionally the target actor
 * @return object
 *         An object that holds the following properties:
 *         - bindSelf: (optional) the self object for the evaluation
 *         - dbgGlobal: the global object reference in the debugger
 */
function getDbgGlobal(options, dbg, webConsole) {
  let evalGlobal = webConsole.evalGlobal;

  if (options.innerWindowID) {
    const window = Services.wm.getCurrentInnerWindowWithId(
      options.innerWindowID
    );

    if (window) {
      evalGlobal = window;
    }
  }

  const dbgGlobal = dbg.makeGlobalObjectReference(evalGlobal);

  // If we have an object to bind to |_self|, create a Debugger.Object
  // referring to that object, belonging to dbg.
  if (!options.selectedObjectActor) {
    return { bindSelf: null, dbgGlobal };
  }

  // All the Object Actors are collected in the Target Actor's "objectsPool",
  // except for objects communicated by the thread actor on pause,
  // or by the JS Tracer.
  // But the "selected object actor" is generated via the console actor evaluation,
  // which stores its objects actor in the target's shared pool.
  const actor = webConsole.targetActor.objectsPool.getActorByID(
    options.selectedObjectActor
  );

  if (!actor) {
    return { bindSelf: null, dbgGlobal };
  }

  const jsVal = actor instanceof LongStringActor ? actor.str : actor.rawObj;
  if (!isObject(jsVal)) {
    return { bindSelf: jsVal, dbgGlobal };
  }

  // If we use the makeDebuggeeValue method of jsVal's own global, then
  // we'll get a D.O that sees jsVal as viewed from its own compartment -
  // that is, without wrappers. The evalWithBindings call will then wrap
  // jsVal appropriately for the evaluation compartment.
  const bindSelf = dbgGlobal.makeDebuggeeValue(jsVal);
  return { bindSelf, dbgGlobal };
}
