/* 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 {DebuggerServer} = require("devtools/server/main");
const Services = require("Services");

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

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

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

const {WebExtensionPolicy} = Cu.getGlobalForObject(XPCOMUtils);

// 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 reportedURI = isSystemPrincipalWindow(window) ?
    Services.io.newURI(window.location.href) : window.document.nodePrincipal.URI;

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

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

  const innerWindowId = window.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIDOMWindowUtils).currentInnerWindowID;

  const errorFlag = 0;

  let {url, lineNumber} = callerInfo;

  let 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.tabActor.window
                        .QueryInterface(Ci.nsIInterfaceRequestor)
                        .getInterface(Ci.nsIDocShell);
  this.docShell.QueryInterface(Ci.nsIWebProgress);

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

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

  this.customizedReloadWindows = new WeakSet();
}

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

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

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

        if (this.userAgent) {
          this.docShell.customUserAgent = this.userAgent;
        }

        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, "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 !== "document-element-inserted") {
      return;
    }

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

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

    let subjectDocShell = window.QueryInterface(Ci.nsIInterfaceRequestor)
                                .getInterface(Ci.nsIWebNavigation)
                                .QueryInterface(Ci.nsIDocShell);

    // 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) {
      let parentWindow = subjectDocShell.sameTypeParent
                                        .QueryInterface(Ci.nsIInterfaceRequestor)
                                        .getInterface(Ci.nsIDOMWindow);
      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, "document-element-inserted");
    }

    // Reset the customized user agent.
    if (this.userAgent && this.docShell.customUserAgent == this.userAgent) {
      this.docShell.customUserAgent = null;
    }

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

    this.stopped = true;
  }
};

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

    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.enabled = false;
        delete this._dbg;
      }
    },

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

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

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

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

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

      let selectedDOMNode;

      if (options.toolboxSelectedNodeActorID) {
        let actor = DebuggerServer.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 dbgObj = dbgWindow.makeDebuggeeValue(object);

          let consoleActor = DebuggerServer.searchAllConnectionsForActor(
            options.toolboxConsoleActorID
          );
          if (consoleActor) {
            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.
     */
    reload(callerInfo, {ignoreCache, userAgent, injectedScript}) {
      if (isSystemPrincipalWindow(this.window)) {
        console.error("Ignored inspectedWindow.reload on system principal target for " +
                      `${callerInfo.url}:${callerInfo.lineNumber}`);
        return {};
      }

      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({
              tabActor: this.tabActor,
              inspectedWindowEval: this.eval.bind(this),
              callerInfo, injectedScript, userAgent, ignoreCache,
            });

            this.customizedReload.start()
                .then(() => {
                  delete this.customizedReload;
                })
                .catch(err => {
                  delete this.customizedReload;
                  console.error(err);
                });
          } 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);
        }
      };

      // 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
     * tabActor 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
     *   `tabActor.window`.
     */
    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.",
          ],
        });
      }

      let 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 && unsafeDereference.toString ?
            unsafeDereference.toString() : String(throwErr);
          const stack = unsafeDereference && 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",
                ],
              });
            }

            let consoleActor = DebuggerServer.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;
