/* 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.EXPORTED_SYMBOLS = ["ExtensionTestUtils"];

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

Components.utils.import("resource://gre/modules/Task.jsm");
Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "Extension",
                                  "resource://gre/modules/Extension.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                  "resource://gre/modules/FileUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Schemas",
                                  "resource://gre/modules/Schemas.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                  "resource://gre/modules/Services.jsm");

XPCOMUtils.defineLazyGetter(this, "Management", () => {
  const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {});
  return Management;
});

/* exported ExtensionTestUtils */

let BASE_MANIFEST = Object.freeze({
  "applications": Object.freeze({
    "gecko": Object.freeze({
      "id": "test@web.ext",
    }),
  }),

  "manifest_version": 2,

  "name": "name",
  "version": "0",
});

class ExtensionWrapper {
  constructor(extension, testScope) {
    this.extension = extension;
    this.testScope = testScope;

    this.state = "uninitialized";

    this.testResolve = null;
    this.testDone = new Promise(resolve => { this.testResolve = resolve; });

    this.messageHandler = new Map();
    this.messageAwaiter = new Map();

    this.messageQueue = new Set();

    this.attachListeners();

    this.testScope.do_register_cleanup(() => {
      if (this.messageQueue.size) {
        let names = Array.from(this.messageQueue, ([msg]) => msg);
        this.testScope.equal(JSON.stringify(names), "[]", "message queue is empty");
      }
      if (this.messageAwaiter.size) {
        let names = Array.from(this.messageAwaiter.keys());
        this.testScope.equal(JSON.stringify(names), "[]", "no tasks awaiting on messages");
      }
    });

    this.testScope.do_register_cleanup(() => {
      if (this.state == "pending" || this.state == "running") {
        this.testScope.equal(this.state, "unloaded", "Extension left running at test shutdown");
        return this.unload();
      } else if (extension.state == "unloading") {
        this.testScope.equal(this.state, "unloaded", "Extension not fully unloaded at test shutdown");
      }
    });

    this.testScope.do_print(`Extension loaded`);
  }

  attachListeners() {
    /* eslint-disable mozilla/balanced-listeners */
    this.extension.on("test-eq", (kind, pass, msg, expected, actual) => {
      this.testScope.ok(pass, `${msg} - Expected: ${expected}, Actual: ${actual}`);
    });
    this.extension.on("test-log", (kind, pass, msg) => {
      this.testScope.do_print(msg);
    });
    this.extension.on("test-result", (kind, pass, msg) => {
      this.testScope.ok(pass, msg);
    });
    this.extension.on("test-done", (kind, pass, msg, expected, actual) => {
      this.testScope.ok(pass, msg);
      this.testResolve(msg);
    });

    this.extension.on("test-message", (kind, msg, ...args) => {
      let handler = this.messageHandler.get(msg);
      if (handler) {
        handler(...args);
      } else {
        this.messageQueue.add([msg, ...args]);
        this.checkMessages();
      }
    });
    /* eslint-enable mozilla/balanced-listeners */
  }

  startup() {
    if (this.state != "uninitialized") {
      throw new Error("Extension already started");
    }
    this.state = "pending";

    return this.extension.startup().then(
      result => {
        this.state = "running";

        return result;
      },
      error => {
        this.state = "failed";

        return Promise.reject(error);
      });
  }

  unload() {
    if (this.state != "running") {
      throw new Error("Extension not running");
    }
    this.state = "unloading";

    this.extension.shutdown();

    this.state = "unloaded";

    return Promise.resolve();
  }

  /*
   * This method marks the extension unloading without actually calling
   * shutdown, since shutting down a MockExtension causes it to be uninstalled.
   *
   * Normally you shouldn't need to use this unless you need to test something
   * that requires a restart, such as updates.
   */
  markUnloaded() {
    if (this.state != "running") {
      throw new Error("Extension not running");
    }
    this.state = "unloaded";

    return Promise.resolve();
  }

  sendMessage(...args) {
    this.extension.testMessage(...args);
  }

  awaitFinish(msg) {
    return this.testDone.then(actual => {
      if (msg) {
        this.testScope.equal(actual, msg, "test result correct");
      }
      return actual;
    });
  }

  checkMessages() {
    for (let message of this.messageQueue) {
      let [msg, ...args] = message;

      let listener = this.messageAwaiter.get(msg);
      if (listener) {
        this.messageQueue.delete(message);
        this.messageAwaiter.delete(msg);

        listener.resolve(...args);
        return;
      }
    }
  }

  checkDuplicateListeners(msg) {
    if (this.messageHandler.has(msg) || this.messageAwaiter.has(msg)) {
      throw new Error("only one message handler allowed");
    }
  }

  awaitMessage(msg) {
    return new Promise(resolve => {
      this.checkDuplicateListeners(msg);

      this.messageAwaiter.set(msg, {resolve});
      this.checkMessages();
    });
  }

  onMessage(msg, callback) {
    this.checkDuplicateListeners(msg);
    this.messageHandler.set(msg, callback);
  }
}

var ExtensionTestUtils = {
  BASE_MANIFEST,

  normalizeManifest: Task.async(function* (manifest, baseManifest = BASE_MANIFEST) {
    yield Management.lazyInit();

    let errors = [];
    let context = {
      url: null,

      logError: error => {
        errors.push(error);
      },

      preprocessors: {},
    };

    manifest = Object.assign({}, baseManifest, manifest);

    let normalized = Schemas.normalize(manifest, "manifest.WebExtensionManifest", context);
    normalized.errors = errors;

    return normalized;
  }),

  currentScope: null,

  profileDir: null,

  init(scope) {
    this.currentScope = scope;

    this.profileDir = scope.do_get_profile();

    // We need to load at least one frame script into every message
    // manager to ensure that the scriptable wrapper for its global gets
    // created before we try to access it externally. If we don't, we
    // fail sanity checks on debug builds the first time we try to
    // create a wrapper, because we should never have a global without a
    // cached wrapper.
    Services.mm.loadFrameScript("data:text/javascript,//", true);


    let tmpD = this.profileDir.clone();
    tmpD.append("tmp");
    tmpD.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);

    let dirProvider = {
      getFile(prop, persistent) {
        persistent.value = false;
        if (prop == "TmpD") {
          return tmpD.clone();
        }
        return null;
      },

      QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider]),
    };
    Services.dirsvc.registerProvider(dirProvider);


    scope.do_register_cleanup(() => {
      tmpD.remove(true);
      Services.dirsvc.unregisterProvider(dirProvider);

      this.currentScope = null;
    });
  },

  addonManagerStarted: false,

  mockAppInfo() {
    const {updateAppInfo} = Cu.import("resource://testing-common/AppInfo.jsm", {});
    updateAppInfo({
      ID: "xpcshell@tests.mozilla.org",
      name: "XPCShell",
      version: "48",
      platformVersion: "48",
    });
  },

  startAddonManager() {
    if (this.addonManagerStarted) {
      return;
    }
    this.addonManagerStarted = true;
    this.mockAppInfo();

    let manager = Cc["@mozilla.org/addons/integration;1"].getService(Ci.nsIObserver)
                                                         .QueryInterface(Ci.nsITimerCallback);
    manager.observe(null, "addons-startup", null);
  },

  loadExtension(data) {
    let extension = Extension.generate(data);

    return new ExtensionWrapper(extension, this.currentScope);
  },
};