/* 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";

var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
var {NetUtil} = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");

XPCOMUtils.defineLazyModuleGetters(this, {
  ExtensionData: "resource://gre/modules/Extension.jsm",
  ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.jsm",
  PerTestCoverageUtils: "resource://testing-common/PerTestCoverageUtils.jsm",
  ServiceWorkerCleanUp: "resource://gre/modules/ServiceWorkerCleanUp.jsm",
});

this.SpecialPowersError = function(aMsg) {
  Error.call(this);
  // let {stack} = new Error();
  this.message = aMsg;
  this.name = "SpecialPowersError";
};
SpecialPowersError.prototype = Object.create(Error.prototype);

SpecialPowersError.prototype.toString = function() {
  return `${this.name}: ${this.message}`;
};

this.SpecialPowersObserverAPI = function SpecialPowersObserverAPI() {
  this._crashDumpDir = null;
  this._processCrashObserversRegistered = false;
  this._chromeScriptListeners = [];
  this._extensions = new Map();
};

function parseKeyValuePairs(text) {
  var lines = text.split("\n");
  var data = {};
  for (let i = 0; i < lines.length; i++) {
    if (lines[i] == "")
      continue;

    // can't just .split() because the value might contain = characters
    let eq = lines[i].indexOf("=");
    if (eq != -1) {
      let [key, value] = [lines[i].substring(0, eq),
                          lines[i].substring(eq + 1)];
      if (key && value)
        data[key] = value.replace(/\\n/g, "\n").replace(/\\\\/g, "\\");
    }
  }
  return data;
}

function parseKeyValuePairsFromFile(file) {
  var fstream = Cc["@mozilla.org/network/file-input-stream;1"].
                createInstance(Ci.nsIFileInputStream);
  fstream.init(file, -1, 0, 0);
  var is = Cc["@mozilla.org/intl/converter-input-stream;1"].
           createInstance(Ci.nsIConverterInputStream);
  is.init(fstream, "UTF-8", 1024, Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER);
  var str = {};
  var contents = "";
  while (is.readString(4096, str) != 0) {
    contents += str.value;
  }
  is.close();
  fstream.close();
  return parseKeyValuePairs(contents);
}

function getTestPlugin(pluginName) {
  var ph = Cc["@mozilla.org/plugin/host;1"]
    .getService(Ci.nsIPluginHost);
  var tags = ph.getPluginTags();
  var name = pluginName || "Test Plug-in";
  for (var tag of tags) {
    if (tag.name == name) {
      return tag;
    }
  }

  return null;
}

SpecialPowersObserverAPI.prototype = {

  _observe(aSubject, aTopic, aData) {
    function addDumpIDToMessage(propertyName) {
      try {
        var id = aSubject.getPropertyAsAString(propertyName);
      } catch (ex) {
        id = null;
      }
      if (id) {
        message.dumpIDs.push({id, extension: "dmp"});
        message.dumpIDs.push({id, extension: "extra"});
      }
    }

    switch (aTopic) {
      case "plugin-crashed":
      case "ipc:content-shutdown":
        var message = { type: "crash-observed", dumpIDs: [] };
        aSubject = aSubject.QueryInterface(Ci.nsIPropertyBag2);
        if (aTopic == "plugin-crashed") {
          addDumpIDToMessage("pluginDumpID");
          addDumpIDToMessage("browserDumpID");

          let pluginID = aSubject.getPropertyAsAString("pluginDumpID");
          let extra = this._getExtraData(pluginID);
          if (extra && ("additional_minidumps" in extra)) {
            let dumpNames = extra.additional_minidumps.split(",");
            for (let name of dumpNames) {
              message.dumpIDs.push({id: pluginID + "-" + name, extension: "dmp"});
            }
          }
        } else { // ipc:content-shutdown
          if (!aSubject.hasKey("abnormal")) {
            return; // This is a normal shutdown, ignore it
          }

          addDumpIDToMessage("dumpID");
        }
        this._sendAsyncMessage("SPProcessCrashService", message);
        break;
    }
  },

  _getCrashDumpDir() {
    if (!this._crashDumpDir) {
      this._crashDumpDir = Services.dirsvc.get("ProfD", Ci.nsIFile);
      this._crashDumpDir.append("minidumps");
    }
    return this._crashDumpDir;
  },

  _getPendingCrashDumpDir() {
    if (!this._pendingCrashDumpDir) {
      this._pendingCrashDumpDir = Services.dirsvc.get("UAppData", Ci.nsIFile);
      this._pendingCrashDumpDir.append("Crash Reports");
      this._pendingCrashDumpDir.append("pending");
    }
    return this._pendingCrashDumpDir;
  },

  _getExtraData(dumpId) {
    let extraFile = this._getCrashDumpDir().clone();
    extraFile.append(dumpId + ".extra");
    if (!extraFile.exists()) {
      return null;
    }
    return parseKeyValuePairsFromFile(extraFile);
  },

  _deleteCrashDumpFiles(aFilenames) {
    var crashDumpDir = this._getCrashDumpDir();
    if (!crashDumpDir.exists()) {
      return false;
    }

    var success = aFilenames.length != 0;
    aFilenames.forEach(function(crashFilename) {
      var file = crashDumpDir.clone();
      file.append(crashFilename);
      if (file.exists()) {
        file.remove(false);
      } else {
        success = false;
      }
    });
    return success;
  },

  _findCrashDumpFiles(aToIgnore) {
    var crashDumpDir = this._getCrashDumpDir();
    var entries = crashDumpDir.exists() && crashDumpDir.directoryEntries;
    if (!entries) {
      return [];
    }

    var crashDumpFiles = [];
    while (entries.hasMoreElements()) {
      var file = entries.nextFile;
      var path = String(file.path);
      if (path.match(/\.(dmp|extra)$/) && !aToIgnore[path]) {
        crashDumpFiles.push(path);
      }
    }
    return crashDumpFiles.concat();
  },

  _deletePendingCrashDumpFiles() {
    var crashDumpDir = this._getPendingCrashDumpDir();
    var removed = false;
    if (crashDumpDir.exists()) {
      let entries = crashDumpDir.directoryEntries;
      while (entries.hasMoreElements()) {
        let file = entries.nextFile;
        if (file.isFile()) {
          file.remove(false);
          removed = true;
        }
      }
    }
    return removed;
  },

  _getURI(url) {
    return Services.io.newURI(url);
  },

  _readUrlAsString(aUrl) {
    // Fetch script content as we can't use scriptloader's loadSubScript
    // to evaluate http:// urls...
    var scriptableStream = Cc["@mozilla.org/scriptableinputstream;1"]
                             .getService(Ci.nsIScriptableInputStream);

    var channel = NetUtil.newChannel({
      uri: aUrl,
      loadUsingSystemPrincipal: true,
    });
    var input = channel.open();
    scriptableStream.init(input);

    var str;
    var buffer = [];

    while ((str = scriptableStream.read(4096))) {
      buffer.push(str);
    }

    var output = buffer.join("");

    scriptableStream.close();
    input.close();

    var status;
    if (channel instanceof Ci.nsIHttpChannel) {
      status = channel.responseStatus;
    }

    if (status == 404) {
      throw new SpecialPowersError(
        "Error while executing chrome script '" + aUrl + "':\n" +
        "The script doesn't exists. Ensure you have registered it in " +
        "'support-files' in your mochitest.ini.");
    }

    return output;
  },

  _sendReply(aMessage, aReplyName, aReplyMsg) {
    let mm = aMessage.target.frameLoader
                     .messageManager;
    mm.sendAsyncMessage(aReplyName, aReplyMsg);
  },

  _notifyCategoryAndObservers(subject, topic, data) {
    const serviceMarker = "service,";

    // First create observers from the category manager.

    let observers = [];

    for (let {value: contractID} of Services.catMan.enumerateCategory(topic)) {
      let factoryFunction;
      if (contractID.substring(0, serviceMarker.length) == serviceMarker) {
        contractID = contractID.substring(serviceMarker.length);
        factoryFunction = "getService";
      } else {
        factoryFunction = "createInstance";
      }

      try {
        let handler = Cc[contractID][factoryFunction]();
        if (handler) {
          let observer = handler.QueryInterface(Ci.nsIObserver);
          observers.push(observer);
        }
      } catch (e) { }
    }

    // Next enumerate the registered observers.
    for (let observer of Services.obs.enumerateObservers(topic)) {
      if (observer instanceof Ci.nsIObserver &&
          !observers.includes(observer)) {
        observers.push(observer);
      }
    }

    observers.forEach(function(observer) {
      try {
        observer.observe(subject, topic, data);
      } catch (e) { }
    });
  },

  /**
   * messageManager callback function
   * This will get requests from our API in the window and process them in chrome for it
   **/
  _receiveMessageAPI(aMessage) { // eslint-disable-line complexity
    // We explicitly return values in the below code so that this function
    // doesn't trigger a flurry of warnings about "does not always return
    // a value".
    switch (aMessage.name) {
      case "SPPrefService": {
        let prefs = Services.prefs;
        let prefType = aMessage.json.prefType.toUpperCase();
        let { prefName, prefValue, iid, defaultValue } = aMessage.json;

        if (aMessage.json.op == "get") {
          if (!prefName || !prefType)
            throw new SpecialPowersError("Invalid parameters for get in SPPrefService");

          // return null if the pref doesn't exist
          if (defaultValue === undefined && prefs.getPrefType(prefName) == prefs.PREF_INVALID)
            return null;
        } else if (aMessage.json.op == "set") {
          if (!prefName || !prefType || prefValue === undefined)
            throw new SpecialPowersError("Invalid parameters for set in SPPrefService");
        } else if (aMessage.json.op == "clear") {
          if (!prefName)
            throw new SpecialPowersError("Invalid parameters for clear in SPPrefService");
        } else {
          throw new SpecialPowersError("Invalid operation for SPPrefService");
        }

        // Now we make the call
        switch (prefType) {
          case "BOOL":
            if (aMessage.json.op == "get") {
              if (defaultValue !== undefined) {
                return prefs.getBoolPref(prefName, defaultValue);
              }
              return prefs.getBoolPref(prefName);
            }
            return prefs.setBoolPref(prefName, prefValue);
          case "INT":
            if (aMessage.json.op == "get") {
              if (defaultValue !== undefined) {
                return prefs.getIntPref(prefName, defaultValue);
              }
              return prefs.getIntPref(prefName);
            }
            return prefs.setIntPref(prefName, prefValue);
          case "CHAR":
            if (aMessage.json.op == "get") {
              if (defaultValue !== undefined) {
                return prefs.getCharPref(prefName, defaultValue);
              }
              return prefs.getCharPref(prefName);
            }
            return prefs.setCharPref(prefName, prefValue);
          case "COMPLEX":
            if (aMessage.json.op == "get")
              return prefs.getComplexValue(prefName, iid);
            return prefs.setComplexValue(prefName, iid, prefValue);
          case "":
            if (aMessage.json.op == "clear") {
              prefs.clearUserPref(prefName);
              return undefined;
            }
        }
        return undefined; // See comment at the beginning of this function.
      }

      case "SPProcessCrashService": {
        switch (aMessage.json.op) {
          case "register-observer":
            this._addProcessCrashObservers();
            break;
          case "unregister-observer":
            this._removeProcessCrashObservers();
            break;
          case "delete-crash-dump-files":
            return this._deleteCrashDumpFiles(aMessage.json.filenames);
          case "find-crash-dump-files":
            return this._findCrashDumpFiles(aMessage.json.crashDumpFilesToIgnore);
          case "delete-pending-crash-dump-files":
            return this._deletePendingCrashDumpFiles();
          default:
            throw new SpecialPowersError("Invalid operation for SPProcessCrashService");
        }
        return undefined; // See comment at the beginning of this function.
      }

      case "SPProcessCrashManagerWait": {
        let promises = aMessage.json.crashIds.map((crashId) => {
          return Services.crashmanager.ensureCrashIsPresent(crashId);
        });

        Promise.all(promises).then(() => {
          this._sendReply(aMessage, "SPProcessCrashManagerWait", {});
        });
        return undefined; // See comment at the beginning of this function.
      }

      case "SPPermissionManager": {
        let msg = aMessage.json;
        let principal = msg.principal;

        switch (msg.op) {
          case "add":
            Services.perms.addFromPrincipal(principal, msg.type, msg.permission, msg.expireType, msg.expireTime);
            break;
          case "remove":
            Services.perms.removeFromPrincipal(principal, msg.type);
            break;
          case "has":
            let hasPerm = Services.perms.testPermissionFromPrincipal(principal, msg.type);
            return hasPerm == Ci.nsIPermissionManager.ALLOW_ACTION;
          case "test":
            let testPerm = Services.perms.testPermissionFromPrincipal(principal, msg.type);
            return testPerm == msg.value;
          default:
            throw new SpecialPowersError(
              "Invalid operation for SPPermissionManager");
        }
        return undefined; // See comment at the beginning of this function.
      }

      case "SPSetTestPluginEnabledState": {
        var plugin = getTestPlugin(aMessage.data.pluginName);
        if (!plugin) {
          return undefined;
        }
        var oldEnabledState = plugin.enabledState;
        plugin.enabledState = aMessage.data.newEnabledState;
        return oldEnabledState;
      }

      case "SPObserverService": {
        let topic = aMessage.json.observerTopic;
        switch (aMessage.json.op) {
          case "notify":
            let data = aMessage.json.observerData;
            Services.obs.notifyObservers(null, topic, data);
            break;
          case "add":
            this._registerObservers._self = this;
            this._registerObservers._add(topic);
            break;
          default:
            throw new SpecialPowersError("Invalid operation for SPObserverervice");
        }
        return undefined; // See comment at the beginning of this function.
      }

      case "SPLoadChromeScript": {
        let id = aMessage.json.id;
        let jsScript;
        let scriptName;

        if (aMessage.json.url) {
          jsScript = this._readUrlAsString(aMessage.json.url);
          scriptName = aMessage.json.url;
        } else if (aMessage.json.function) {
          jsScript = aMessage.json.function.body;
          scriptName = aMessage.json.function.name
            || "<loadChromeScript anonymous function>";
        } else {
          throw new SpecialPowersError("SPLoadChromeScript: Invalid script");
        }

        // Setup a chrome sandbox that has access to sendAsyncMessage
        // and addMessageListener in order to communicate with
        // the mochitest.
        let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
        let sandboxOptions = Object.assign({wantGlobalProperties: ["ChromeUtils"]},
                                           aMessage.json.sandboxOptions);
        let sb = Cu.Sandbox(systemPrincipal, sandboxOptions);
        let mm = aMessage.target.frameLoader
                         .messageManager;
        sb.sendAsyncMessage = (name, message) => {
          mm.sendAsyncMessage("SPChromeScriptMessage",
                              { id, name, message });
        };
        sb.addMessageListener = (name, listener) => {
          this._chromeScriptListeners.push({ id, name, listener });
        };
        sb.browserElement = aMessage.target;

        // Also expose assertion functions
        let reporter = function(err, message, stack) {
          // Pipe assertions back to parent process
          mm.sendAsyncMessage("SPChromeScriptAssert",
                              { id, name: scriptName, err, message,
                                stack });
        };
        Object.defineProperty(sb, "assert", {
          get() {
            let scope = Cu.createObjectIn(sb);
            Services.scriptloader.loadSubScript("resource://specialpowers/Assert.jsm",
                                                scope);

            let assert = new scope.Assert(reporter);
            delete sb.assert;
            return sb.assert = assert;
          },
          configurable: true,
        });

        // Evaluate the chrome script
        try {
          Cu.evalInSandbox(jsScript, sb, "1.8", scriptName, 1);
        } catch (e) {
          throw new SpecialPowersError(
            "Error while executing chrome script '" + scriptName + "':\n" +
            e + "\n" +
            e.fileName + ":" + e.lineNumber);
        }
        return undefined; // See comment at the beginning of this function.
      }

      case "SPChromeScriptMessage": {
        let id = aMessage.json.id;
        let name = aMessage.json.name;
        let message = aMessage.json.message;
        return this._chromeScriptListeners
                   .filter(o => (o.name == name && o.id == id))
                   .map(o => o.listener(message));
      }

      case "SPImportInMainProcess": {
        var message = { hadError: false, errorMessage: null };
        try {
          ChromeUtils.import(aMessage.data);
        } catch (e) {
          message.hadError = true;
          message.errorMessage = e.toString();
        }
        return message;
      }

      case "SPCleanUpSTSData": {
        let origin = aMessage.data.origin;
        let flags = aMessage.data.flags;
        let uri = Services.io.newURI(origin);
        let sss = Cc["@mozilla.org/ssservice;1"].
                  getService(Ci.nsISiteSecurityService);
        sss.removeState(Ci.nsISiteSecurityService.HEADER_HSTS, uri, flags);
        return undefined;
      }

      case "SPRequestDumpCoverageCounters": {
        PerTestCoverageUtils.afterTest().then(() =>
          this._sendReply(aMessage, "SPRequestDumpCoverageCounters", {})
        );
        return undefined; // See comment at the beginning of this function.
      }

      case "SPRequestResetCoverageCounters": {
        PerTestCoverageUtils.beforeTest().then(() =>
          this._sendReply(aMessage, "SPRequestResetCoverageCounters", {})
        );
        return undefined; // See comment at the beginning of this function.
      }

      case "SPCheckServiceWorkers": {
        let swm = Cc["@mozilla.org/serviceworkers/manager;1"]
                    .getService(Ci.nsIServiceWorkerManager);
        let regs = swm.getAllRegistrations();

        // XXX This code is shared with specialpowers.js.
        let workers = new Array(regs.length);
        for (let i = 0; i < regs.length; ++i) {
          let { scope, scriptSpec } = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo);
          workers[i] = { scope, scriptSpec };
        }
        return { workers };
      }

      case "SPLoadExtension": {
        let id = aMessage.data.id;
        let ext = aMessage.data.ext;
        let extension = ExtensionTestCommon.generate(ext);

        let resultListener = (...args) => {
          this._sendReply(aMessage, "SPExtensionMessage", {id, type: "testResult", args});
        };

        let messageListener = (...args) => {
          args.shift();
          this._sendReply(aMessage, "SPExtensionMessage", {id, type: "testMessage", args});
        };

        // Register pass/fail handlers.
        extension.on("test-result", resultListener);
        extension.on("test-eq", resultListener);
        extension.on("test-log", resultListener);
        extension.on("test-done", resultListener);

        extension.on("test-message", messageListener);

        this._extensions.set(id, extension);
        return undefined;
      }

      case "SPStartupExtension": {
        let id = aMessage.data.id;
        // This is either an Extension, or (if useAddonManager is set) a MockExtension.
        let extension = this._extensions.get(id);
        extension.on("startup", (eventName, ext) => {
          if (!ext) {
            // ext is only set by the "startup" event from Extension.jsm.
            // Unfortunately ext-backgroundPage.js emits an event with the same
            // name, but without the extension object as parameter.
            return;
          }
          // ext is always the "real" Extension object, even when "extension"
          // is a MockExtension.
          this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionSetId", args: [ext.id, ext.uuid]});
        });

        // Make sure the extension passes the packaging checks when
        // they're run on a bare archive rather than a running instance,
        // as the add-on manager runs them.
        let extensionData = new ExtensionData(extension.rootURI);
        extensionData.loadManifest().then(
          () => {
            return extensionData.initAllLocales().then(() => {
              if (extensionData.errors.length) {
                return Promise.reject("Extension contains packaging errors");
              }
              return undefined;
            });
          },
          () => {
            // loadManifest() will throw if we're loading an embedded
            // extension, so don't worry about locale errors in that
            // case.
          }
        ).then(async () => {
          // browser tests do not call startup in ExtensionXPCShellUtils or MockExtension,
          // in that case we have an ID here and we need to set the override.
          if (extension.id) {
            await ExtensionTestCommon.setIncognitoOverride(extension);
          }
          return extension.startup();
        }).then(() => {
          this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionStarted", args: []});
        }).catch(e => {
          dump(`Extension startup failed: ${e}\n${e.stack}`);
          this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionFailed", args: []});
        });
        return undefined;
      }

      case "SPExtensionMessage": {
        let id = aMessage.data.id;
        let extension = this._extensions.get(id);
        extension.testMessage(...aMessage.data.args);
        return undefined;
      }

      case "SPUnloadExtension": {
        let id = aMessage.data.id;
        let extension = this._extensions.get(id);
        this._extensions.delete(id);
        let done = async () => {
          await extension._uninstallPromise;
          this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionUnloaded", args: []});
        };
        extension.shutdown().then(done, done);
        return undefined;
      }

      case "SPRemoveAllServiceWorkers": {
        ServiceWorkerCleanUp.removeAll().then(() => {
          this._sendReply(aMessage, "SPServiceWorkerCleanupComplete", { id: aMessage.data.id });
        });
        return undefined;
      }

      case "SPRemoveServiceWorkerDataForExampleDomain": {
        ServiceWorkerCleanUp.removeFromHost("example.com").then(() => {
          this._sendReply(aMessage, "SPServiceWorkerCleanupComplete", { id: aMessage.data.id });
        });
        return undefined;
      }

      default:
        throw new SpecialPowersError(`Unrecognized Special Powers API: ${aMessage.name}`);
    }

    // We throw an exception before reaching this explicit return because
    // we should never be arriving here anyway.
    throw new SpecialPowersError("Unreached code"); // eslint-disable-line no-unreachable
    return undefined;
  },
};
