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

/**
 * This module contains extension testing helper logic which is common
 * between all test suites.
 */

/* exported ExtensionTestCommon, MockExtension */

this.EXPORTED_SYMBOLS = ["ExtensionTestCommon", "MockExtension"];

const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;

Cu.importGlobalProperties(["TextEncoder"]);

Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                  "resource://gre/modules/AddonManager.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                  "resource://gre/modules/AppConstants.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                  "resource://gre/modules/Extension.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionParent",
                                  "resource://gre/modules/ExtensionParent.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                  "resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                  "resource://gre/modules/osfile.jsm");

XPCOMUtils.defineLazyGetter(this, "apiManager",
                            () => ExtensionParent.apiManager);

Cu.import("resource://gre/modules/ExtensionUtils.jsm");

XPCOMUtils.defineLazyServiceGetter(this, "uuidGen",
                                   "@mozilla.org/uuid-generator;1",
                                   "nsIUUIDGenerator");

const {
  flushJarCache,
  instanceOf,
} = ExtensionUtils;

XPCOMUtils.defineLazyGetter(this, "console", ExtensionUtils.getConsole);


/**
 * A skeleton Extension-like object, used for testing, which installs an
 * add-on via the add-on manager when startup() is called, and
 * uninstalles it on shutdown().
 *
 * @param {string} id
 * @param {nsIFile} file
 * @param {nsIURI} rootURI
 * @param {string} installType
 */
class MockExtension {
  constructor(file, rootURI, installType) {
    this.id = null;
    this.file = file;
    this.rootURI = rootURI;
    this.installType = installType;
    this.addon = null;

    let promiseEvent = eventName => new Promise(resolve => {
      let onstartup = (msg, extension) => {
        if (this.addon && extension.id == this.addon.id) {
          apiManager.off(eventName, onstartup);

          this.id = extension.id;
          this._extension = extension;
          resolve(extension);
        }
      };
      apiManager.on(eventName, onstartup);
    });

    this._extension = null;
    this._extensionPromise = promiseEvent("startup");
    this._readyPromise = promiseEvent("ready");
  }

  testMessage(...args) {
    return this._extension.testMessage(...args);
  }

  on(...args) {
    this._extensionPromise.then(extension => {
      extension.on(...args);
    });
  }

  off(...args) {
    this._extensionPromise.then(extension => {
      extension.off(...args);
    });
  }

  startup() {
    if (this.installType == "temporary") {
      return AddonManager.installTemporaryAddon(this.file).then(addon => {
        this.addon = addon;
        return this._readyPromise;
      });
    } else if (this.installType == "permanent") {
      return new Promise((resolve, reject) => {
        AddonManager.getInstallForFile(this.file, install => {
          let listener = {
            onInstallFailed: reject,
            onInstallEnded: (install, newAddon) => {
              this.addon = newAddon;
              resolve(this._readyPromise);
            },
          };

          install.addListener(listener);
          install.install();
        });
      });
    }
    throw new Error("installType must be one of: temporary, permanent");
  }

  shutdown() {
    this.addon.uninstall();
    return this.cleanupGeneratedFile();
  }

  cleanupGeneratedFile() {
    flushJarCache(this.file);
    return OS.File.remove(this.file.path);
  }
}

class ExtensionTestCommon {
  /**
   * This code is designed to make it easy to test a WebExtension
   * without creating a bunch of files. Everything is contained in a
   * single JSON blob.
   *
   * Properties:
   *   "background": "<JS code>"
   *     A script to be loaded as the background script.
   *     The "background" section of the "manifest" property is overwritten
   *     if this is provided.
   *   "manifest": {...}
   *     Contents of manifest.json
   *   "files": {"filename1": "contents1", ...}
   *     Data to be included as files. Can be referenced from the manifest.
   *     If a manifest file is provided here, it takes precedence over
   *     a generated one. Always use "/" as a directory separator.
   *     Directories should appear here only implicitly (as a prefix
   *     to file names)
   *
   * To make things easier, the value of "background" and "files"[] can
   * be a function, which is converted to source that is run.
   *
   * The generated extension is stored in the system temporary directory,
   * and an nsIFile object pointing to it is returned.
   *
   * @param {object} data
   * @returns {nsIFile}
   */
  static generateXPI(data) {
    let manifest = data.manifest;
    if (!manifest) {
      manifest = {};
    }

    let files = data.files;
    if (!files) {
      files = {};
    }

    function provide(obj, keys, value, override = false) {
      if (keys.length == 1) {
        if (!(keys[0] in obj) || override) {
          obj[keys[0]] = value;
        }
      } else {
        if (!(keys[0] in obj)) {
          obj[keys[0]] = {};
        }
        provide(obj[keys[0]], keys.slice(1), value, override);
      }
    }

    provide(manifest, ["name"], "Generated extension");
    provide(manifest, ["manifest_version"], 2);
    provide(manifest, ["version"], "1.0");

    if (data.background) {
      let bgScript = uuidGen.generateUUID().number + ".js";

      provide(manifest, ["background", "scripts"], [bgScript], true);
      files[bgScript] = data.background;
    }

    provide(files, ["manifest.json"], manifest);

    if (data.embedded) {
      // Package this as a webextension embedded inside a legacy
      // extension.

      let xpiFiles = {
        "install.rdf": `<?xml version="1.0" encoding="UTF-8"?>
          <RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
               xmlns:em="http://www.mozilla.org/2004/em-rdf#">
              <Description about="urn:mozilla:install-manifest"
                  em:id="${manifest.applications.gecko.id}"
                  em:name="${manifest.name}"
                  em:type="2"
                  em:version="${manifest.version}"
                  em:description=""
                  em:hasEmbeddedWebExtension="true"
                  em:bootstrap="true">

                  <!-- Firefox -->
                  <em:targetApplication>
                      <Description
                          em:id="{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"
                          em:minVersion="51.0a1"
                          em:maxVersion="*"/>
                  </em:targetApplication>
              </Description>
          </RDF>
        `,

        "bootstrap.js": `
          function install() {}
          function uninstall() {}
          function shutdown() {}

          function startup(data) {
            data.webExtension.startup();
          }
        `,
      };

      for (let [path, data] of Object.entries(files)) {
        xpiFiles[`webextension/${path}`] = data;
      }

      files = xpiFiles;
    }

    return this.generateZipFile(files);
  }

  static generateZipFile(files, baseName = "generated-extension.xpi") {
    let ZipWriter = Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter");
    let zipW = new ZipWriter();

    let file = FileUtils.getFile("TmpD", [baseName]);
    file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);

    const MODE_WRONLY = 0x02;
    const MODE_TRUNCATE = 0x20;
    zipW.open(file, MODE_WRONLY | MODE_TRUNCATE);

    // Needs to be in microseconds for some reason.
    let time = Date.now() * 1000;

    function generateFile(filename) {
      let components = filename.split("/");
      let path = "";
      for (let component of components.slice(0, -1)) {
        path += component + "/";
        if (!zipW.hasEntry(path)) {
          zipW.addEntryDirectory(path, time, false);
        }
      }
    }

    for (let filename in files) {
      let script = files[filename];
      if (typeof(script) == "function") {
        script = "(" + script.toString() + ")()";
      } else if (instanceOf(script, "Object") || instanceOf(script, "Array")) {
        script = JSON.stringify(script);
      }

      if (!instanceOf(script, "ArrayBuffer")) {
        script = new TextEncoder("utf-8").encode(script).buffer;
      }

      let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(Ci.nsIArrayBufferInputStream);
      stream.setData(script, 0, script.byteLength);

      generateFile(filename);
      zipW.addEntryStream(filename, time, 0, stream, false);
    }

    zipW.close();

    return file;
  }

  /**
   * Generates a new extension using |Extension.generateXPI|, and initializes a
   * new |Extension| instance which will execute it.
   *
   * @param {object} data
   * @returns {Extension}
   */
  static generate(data) {
    let file = this.generateXPI(data);

    flushJarCache(file);
    Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {path: file.path});

    let fileURI = Services.io.newFileURI(file);
    let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/", null, null);

    // This may be "temporary" or "permanent".
    if (data.useAddonManager) {
      return new MockExtension(file, jarURI, data.useAddonManager);
    }

    let id;
    if (data.manifest) {
      if (data.manifest.applications && data.manifest.applications.gecko) {
        id = data.manifest.applications.gecko.id;
      } else if (data.manifest.browser_specific_settings && data.manifest.browser_specific_settings.gecko) {
        id = data.manifest.browser_specific_settings.gecko.id;
      }
    }
    if (!id) {
      id = uuidGen.generateUUID().number;
    }

    return new Extension({
      id,
      resourceURI: jarURI,
      cleanupFile: file,
    });
  }
}