/* 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 protocol = require("devtools/shared/protocol");

const { Cc, Ci, Cu, Cr } = require("chrome");

const { DevToolsServer } = require("devtools/server/devtools-server");
const Services = require("Services");
const ChromeUtils = require("ChromeUtils");

loader.lazyGetter(
  this,
  "NodeActor",
  () => require("devtools/server/actors/inspector/node").NodeActor,
  true
);

const {
  webExtensionInspectedWindowSpec,
} = require("devtools/shared/specs/addon/webextension-inspected-window");

const { WebExtensionPolicy } = Cu.getGlobalForObject(
  require("resource://gre/modules/XPCOMUtils.jsm")
);

// A weak set of the documents for which a warning message has been
// already logged (so that we don't keep emitting the same warning if an
// extension keeps calling the devtools.inspectedWindow.eval API method
// when it fails to retrieve a result, but we do log the warning message
// if the user reloads the window):
//
// WeakSet<Document>
const deniedWarningDocuments = new WeakSet();

function isSystemPrincipalWindow(window) {
  return window.document.nodePrincipal.isSystemPrincipal;
}

// Create the exceptionInfo property in the format expected by a
// WebExtension inspectedWindow.eval API calls.
function createExceptionInfoResult(props) {
  return {
    exceptionInfo: {
      isError: true,
      code: "E_PROTOCOLERROR",
      description: "Unknown Inspector protocol error",

      // Apply the passed properties.
      ...props,
    },
  };
}

// Show a warning message in the webconsole when an extension
// eval request has been denied, so that the user knows about it
// even if the extension doesn't report the error itself.
function logAccessDeniedWarning(window, callerInfo, extensionPolicy) {
  // Do not log the same warning multiple times for the same document.
  if (deniedWarningDocuments.has(window.document)) {
    return;
  }

  deniedWarningDocuments.add(window.document);

  const { name } = extensionPolicy;

  // System principals have a null nodePrincipal.URI and so we use
  // the url from window.location.href.
  const reportedURIorPrincipal = isSystemPrincipalWindow(window)
    ? Services.io.newURI(window.location.href)
    : window.document.nodePrincipal;

  const error = Cc["@mozilla.org/scripterror;1"].createInstance(
    Ci.nsIScriptError
  );

  const msg = `The extension "${name}" is not allowed to access ${reportedURIorPrincipal.spec}`;

  const innerWindowId = window.windowGlobalChild.innerWindowId;

  const errorFlag = 0;

  let { url, lineNumber } = callerInfo;

  const callerURI = callerInfo.url && Services.io.newURI(callerInfo.url);

  // callerInfo.url is not the full path to the file that called the WebExtensions
  // API yet (Bug 1448878), and so we associate the error to the url of the extension
  // manifest.json file as a fallback.
  if (callerURI.filePath === "/") {
    url = extensionPolicy.getURL("/manifest.json");
    lineNumber = null;
  }

  error.initWithWindowID(
    msg,
    url,
    lineNumber,
    0,
    0,
    errorFlag,
    "webExtensions",
    innerWindowId
  );
  Services.console.logMessage(error);
}

function CustomizedReload(params) {
  this.docShell = params.targetActor.window.docShell;
  this.docShell.QueryInterface(Ci.nsIWebProgress);

  this.inspectedWindowEval = params.inspectedWindowEval;
  this.callerInfo = params.callerInfo;

  this.ignoreCache = params.ignoreCache;
  this.injectedScript = params.injectedScript;

  this.customizedReloadWindows = new WeakSet();
}

CustomizedReload.prototype = {
  QueryInterface: ChromeUtils.generateQI([
    "nsIWebProgressListener",
    "nsISupportsWeakReference",
  ]),
  get window() {
    return this.docShell.DOMWindow;
  },

  get webNavigation() {
    return this.docShell
      .QueryInterface(Ci.nsIInterfaceRequestor)
      .getInterface(Ci.nsIWebNavigation);
  },

  get browsingContext() {
    return this.docShell.browsingContext;
  },

  start() {
    if (!this.waitForReloadCompleted) {
      this.waitForReloadCompleted = new Promise((resolve, reject) => {
        this.resolveReloadCompleted = resolve;
        this.rejectReloadCompleted = reject;

        let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;

        if (this.ignoreCache) {
          reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
        }

        try {
          if (this.injectedScript) {
            // Listen to the newly created document elements only if there is an
            // injectedScript to evaluate.
            Services.obs.addObserver(this, "initial-document-element-inserted");
          }

          // Watch the loading progress and clear the current CustomizedReload once the
          // page has been reloaded (or if its reloading has been interrupted).
          this.docShell.addProgressListener(
            this,
            Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT
          );

          this.webNavigation.reload(reloadFlags);
        } catch (err) {
          // Cancel the injected script listener if the reload fails
          // (which will also report the error by rejecting the promise).
          this.stop(err);
        }
      });
    }

    return this.waitForReloadCompleted;
  },

  observe(subject, topic, data) {
    if (topic !== "initial-document-element-inserted") {
      return;
    }

    const document = subject;
    const window = document?.defaultView;

    // Filter out non interesting documents.
    if (!document || !document.location || !window) {
      return;
    }

    const subjectDocShell = window.docShell;

    // Keep track of the set of window objects where we are going to inject
    // the injectedScript: the top level window and all its descendant
    // that are still of type content (filtering out loaded XUL pages, if any).
    if (window == this.window) {
      this.customizedReloadWindows.add(window);
    } else if (subjectDocShell.sameTypeParent) {
      const parentWindow = subjectDocShell.sameTypeParent.domWindow;
      if (parentWindow && this.customizedReloadWindows.has(parentWindow)) {
        this.customizedReloadWindows.add(window);
      }
    }

    if (this.customizedReloadWindows.has(window)) {
      const { apiErrorResult } = this.inspectedWindowEval(
        this.callerInfo,
        this.injectedScript,
        {},
        window
      );

      // Log only apiErrorResult, because no one is waiting for the
      // injectedScript result, and any exception is going to be logged
      // in the inspectedWindow webconsole.
      if (apiErrorResult) {
        console.error(
          "Unexpected Error in injectedScript during inspectedWindow.reload for",
          `${this.callerInfo.url}:${this.callerInfo.lineNumber}`,
          apiErrorResult
        );
      }
    }
  },

  onStateChange(webProgress, request, state, status) {
    if (webProgress.DOMWindow !== this.window) {
      return;
    }

    if (state & Ci.nsIWebProgressListener.STATE_STOP) {
      if (status == Cr.NS_BINDING_ABORTED) {
        // The customized reload has been interrupted and we can clear
        // the CustomizedReload and reject the promise.
        const url = this.window.location.href;
        this.stop(
          new Error(
            `devtools.inspectedWindow.reload on ${url} has been interrupted`
          )
        );
      } else {
        // Once the top level frame has been loaded, we can clear the customized reload
        // and resolve the promise.
        this.stop();
      }
    }
  },

  stop(error) {
    if (this.stopped) {
      return;
    }

    this.docShell.removeProgressListener(this);

    if (this.injectedScript) {
      Services.obs.removeObserver(this, "initial-document-element-inserted");
    }

    if (error) {
      this.rejectReloadCompleted(error);
    } else {
      this.resolveReloadCompleted();
    }

    this.stopped = true;
  },
};

var WebExtensionInspectedWindowActor = protocol.ActorClassWithSpec(
  webExtensionInspectedWindowSpec,
  {
    /**
     * Created the WebExtension InspectedWindow actor
     */
    initialize(conn, targetActor) {
      protocol.Actor.prototype.initialize.call(this, conn);
      this.targetActor = targetActor;
    },

    destroy(conn) {
      protocol.Actor.prototype.destroy.call(this, conn);

      if (this.customizedReload) {
        this.customizedReload.stop(
          new Error("WebExtensionInspectedWindowActor destroyed")
        );
        delete this.customizedReload;
      }

      if (this._dbg) {
        this._dbg.disable();
        delete this._dbg;
      }
    },

    get dbg() {
      if (this._dbg) {
        return this._dbg;
      }

      this._dbg = this.targetActor.makeDebugger();
      return this._dbg;
    },

    get window() {
      return this.targetActor.window;
    },

    get webNavigation() {
      return this.targetActor.webNavigation;
    },

    createEvalBindings(dbgWindow, options) {
      const bindings = Object.create(null);

      let selectedDOMNode;

      if (options.toolboxSelectedNodeActorID) {
        const actor = DevToolsServer.searchAllConnectionsForActor(
          options.toolboxSelectedNodeActorID
        );
        if (actor && actor instanceof NodeActor) {
          selectedDOMNode = actor.rawNode;
        }
      }

      Object.defineProperty(bindings, "$0", {
        enumerable: true,
        configurable: true,
        get: () => {
          if (selectedDOMNode && !Cu.isDeadWrapper(selectedDOMNode)) {
            return dbgWindow.makeDebuggeeValue(selectedDOMNode);
          }

          return undefined;
        },
      });

      // This function is used by 'eval' and 'reload' requests, but only 'eval'
      // passes 'toolboxConsoleActor' from the client side in order to set
      // the 'inspect' binding.
      Object.defineProperty(bindings, "inspect", {
        enumerable: true,
        configurable: true,
        value: dbgWindow.makeDebuggeeValue(object => {
          const consoleActor = DevToolsServer.searchAllConnectionsForActor(
            options.toolboxConsoleActorID
          );
          if (consoleActor) {
            const dbgObj = consoleActor.makeDebuggeeValue(object);
            consoleActor.inspectObject(
              dbgObj,
              "webextension-devtools-inspectedWindow-eval"
            );
          } else {
            // TODO(rpl): evaluate if it would be better to raise an exception
            // to the caller code instead.
            console.error("Toolbox Console RDP Actor not found");
          }
        }),
      });

      return bindings;
    },

    /**
     * Reload the target tab, optionally bypass cache, customize the userAgent and/or
     * inject a script in targeted document or any of its sub-frame.
     *
     * @param {webExtensionCallerInfo} callerInfo
     *   the addonId and the url (the addon base url or the url of the actual caller
     *   filename and lineNumber) used to log useful debugging information in the
     *   produced error logs and eval stack trace.
     *
     * @param {webExtensionReloadOptions} options
     *   used to optionally enable the reload customizations.
     * @param {boolean|undefined}       options.ignoreCache
     *   enable/disable the cache bypass headers.
     * @param {string|undefined}        options.userAgent
     *   customize the userAgent during the page reload.
     * @param {string|undefined}        options.injectedScript
     *   evaluate the provided javascript code in the top level and every sub-frame
     *   created during the page reload, before any other script in the page has been
     *   executed.
     */
    async reload(callerInfo, { ignoreCache, userAgent, injectedScript }) {
      if (isSystemPrincipalWindow(this.window)) {
        console.error(
          "Ignored inspectedWindow.reload on system principal target for " +
            `${callerInfo.url}:${callerInfo.lineNumber}`
        );
        return {};
      }

      await new Promise(resolve => {
        const delayedReload = () => {
          // This won't work while the browser is shutting down and we don't really
          // care.
          if (Services.startup.shuttingDown) {
            return;
          }

          if (injectedScript || userAgent) {
            if (this.customizedReload) {
              // TODO(rpl): check what chrome does, and evaluate if queue the new reload
              // after the current one has been completed.
              console.error(
                "Reload already in progress. Ignored inspectedWindow.reload for " +
                  `${callerInfo.url}:${callerInfo.lineNumber}`
              );
              return;
            }

            try {
              this.customizedReload = new CustomizedReload({
                targetActor: this.targetActor,
                inspectedWindowEval: this.eval.bind(this),
                callerInfo,
                injectedScript,
                ignoreCache,
              });

              this.customizedReload
                .start()
                .catch(err => {
                  console.error(err);
                })
                .then(() => {
                  delete this.customizedReload;
                  resolve();
                });
            } catch (err) {
              // Cancel the customized reload (if any) on exception during the
              // reload setup.
              if (this.customizedReload) {
                this.customizedReload.stop(err);
              }
              throw err;
            }
          } else {
            // If there is no custom user agent and/or injected script, then
            // we can reload the target without subscribing any observer/listener.
            let reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
            if (ignoreCache) {
              reloadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
            }
            this.webNavigation.reload(reloadFlags);
            resolve();
          }
        };

        // Execute the reload in a dispatched runnable, so that we can
        // return the reply to the caller before the reload is actually
        // started.
        Services.tm.dispatchToMainThread(delayedReload);
      });

      return {};
    },

    /**
     * Evaluate the provided javascript code in a target window (that is always the
     * targetActor window when called through RDP protocol, or the passed
     * customTargetWindow when called directly from the CustomizedReload instances).
     *
     * @param {webExtensionCallerInfo} callerInfo
     *   the addonId and the url (the addon base url or the url of the actual caller
     *   filename and lineNumber) used to log useful debugging information in the
     *   produced error logs and eval stack trace.
     *
     * @param {string} expression
     *   the javascript code to be evaluated in the target window
     *
     * @param {webExtensionEvalOptions} evalOptions
     *   used to optionally enable the eval customizations.
     *   NOTE: none of the eval options is currently implemented, they will be already
     *   reported as unsupported by the WebExtensions schema validation wrappers, but
     *   an additional level of error reporting is going to be applied here, so that
     *   if the server and the client have different ideas of which option is supported
     *   the eval call result will contain detailed informations (in the format usually
     *   expected for errors not raised in the evaluated javascript code).
     *
     * @param {DOMWindow|undefined} customTargetWindow
     *   Used in the CustomizedReload instances to evaluate the `injectedScript`
     *   javascript code in every sub-frame of the target window during the tab reload.
     *   NOTE: this parameter is not part of the RDP protocol exposed by this actor, when
     *   it is called over the remote debugging protocol the target window is always
     *   `targetActor.window`.
     */
    // eslint-disable-next-line complexity
    eval(callerInfo, expression, options, customTargetWindow) {
      const window = customTargetWindow || this.window;
      options = options || {};

      const extensionPolicy = WebExtensionPolicy.getByID(callerInfo.addonId);

      if (!extensionPolicy) {
        return createExceptionInfoResult({
          description: "Inspector protocol error: %s %s",
          details: ["Caller extension not found for", callerInfo.url],
        });
      }

      if (!window) {
        return createExceptionInfoResult({
          description: "Inspector protocol error: %s",
          details: [
            "The target window is not defined. inspectedWindow.eval not executed.",
          ],
        });
      }

      // Log the error for the user to know that the extension request has been denied
      // (the extension may not warn the user at all).
      const logEvalDenied = () => {
        logAccessDeniedWarning(window, callerInfo, extensionPolicy);
      };

      if (isSystemPrincipalWindow(window)) {
        logEvalDenied();

        // On denied JS evaluation, report it to the extension using the same data format
        // used in the corresponding chrome API method to report issues that are
        // not exceptions raised in the evaluated javascript code.
        return createExceptionInfoResult({
          description: "Inspector protocol error: %s",
          details: [
            "This target has a system principal. inspectedWindow.eval denied.",
          ],
        });
      }

      const docPrincipalURI = window.document.nodePrincipal.URI;

      // Deny on document principals listed as restricted or
      // related to the about: pages (only about:blank and about:srcdoc are
      // allowed and their are expected to not have their about URI associated
      // to the principal).
      if (
        WebExtensionPolicy.isRestrictedURI(docPrincipalURI) ||
        docPrincipalURI.schemeIs("about")
      ) {
        logEvalDenied();

        return createExceptionInfoResult({
          description: "Inspector protocol error: %s %s",
          details: [
            "This extension is not allowed on the current inspected window origin",
            docPrincipalURI.spec,
          ],
        });
      }

      const windowAddonId = window.document.nodePrincipal.addonId;

      if (windowAddonId && extensionPolicy.id !== windowAddonId) {
        logEvalDenied();

        return createExceptionInfoResult({
          description: "Inspector protocol error: %s on %s",
          details: [
            "This extension is not allowed to access this extension page.",
            window.document.location.origin,
          ],
        });
      }

      // Raise an error on the unsupported options.
      if (
        options.frameURL ||
        options.contextSecurityOrigin ||
        options.useContentScriptContext
      ) {
        return createExceptionInfoResult({
          description: "Inspector protocol error: %s",
          details: [
            "The inspectedWindow.eval options are currently not supported",
          ],
        });
      }

      const dbgWindow = this.dbg.makeGlobalObjectReference(window);

      let evalCalledFrom = callerInfo.url;
      if (callerInfo.lineNumber) {
        evalCalledFrom += `:${callerInfo.lineNumber}`;
      }

      const bindings = this.createEvalBindings(dbgWindow, options);

      const result = dbgWindow.executeInGlobalWithBindings(
        expression,
        bindings,
        {
          url: `debugger eval called from ${evalCalledFrom} - eval code`,
        }
      );

      let evalResult;

      if (result) {
        if ("return" in result) {
          evalResult = result.return;
        } else if ("yield" in result) {
          evalResult = result.yield;
        } else if ("throw" in result) {
          const throwErr = result.throw;

          // XXXworkers: Calling unsafeDereference() returns an object with no
          // toString method in workers. See Bug 1215120.
          const unsafeDereference =
            throwErr &&
            typeof throwErr === "object" &&
            throwErr.unsafeDereference();
          const message = unsafeDereference?.toString
            ? unsafeDereference.toString()
            : String(throwErr);
          const stack = unsafeDereference?.stack
            ? unsafeDereference.stack
            : null;

          return {
            exceptionInfo: {
              isException: true,
              value: `${message}\n\t${stack}`,
            },
          };
        }
      } else {
        // TODO(rpl): can the result of executeInGlobalWithBinding be null or
        // undefined? (which means that it is not a return, a yield or a throw).
        console.error(
          "Unexpected empty inspectedWindow.eval result for",
          `${callerInfo.url}:${callerInfo.lineNumber}`
        );
      }

      if (evalResult) {
        try {
          // Return the evalResult as a grip (used by the WebExtensions
          // devtools inspector's sidebar.setExpression API method).
          if (options.evalResultAsGrip) {
            if (!options.toolboxConsoleActorID) {
              return createExceptionInfoResult({
                description: "Inspector protocol error: %s - %s",
                details: [
                  "Unexpected invalid sidebar panel expression request",
                  "missing toolboxConsoleActorID",
                ],
              });
            }

            const consoleActor = DevToolsServer.searchAllConnectionsForActor(
              options.toolboxConsoleActorID
            );

            return { valueGrip: consoleActor.createValueGrip(evalResult) };
          }

          if (evalResult && typeof evalResult === "object") {
            evalResult = evalResult.unsafeDereference();
          }
          evalResult = JSON.parse(JSON.stringify(evalResult));
        } catch (err) {
          // The evaluation result cannot be sent over the RDP Protocol,
          // report it as with the same data format used in the corresponding
          // chrome API method.
          return createExceptionInfoResult({
            description: "Inspector protocol error: %s",
            details: [String(err)],
          });
        }
      }

      return { value: evalResult };
    },
  }
);

exports.WebExtensionInspectedWindowActor = WebExtensionInspectedWindowActor;
