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

module.metadata = {
  "stability": "unstable"
};

const { Class } = require('../core/heritage');
const { EventTarget } = require('../event/target');
const { on, off, emit, setListeners } = require('../event/core');
const {
  attach, detach, destroy
} = require('./utils');
const { method } = require('../lang/functional');
const { Ci, Cu, Cc } = require('chrome');
const unload = require('../system/unload');
const events = require('../system/events');
const { getInnerId } = require("../window/utils");
const { WorkerSandbox } = require('./sandbox');
const { getTabForWindow } = require('../tabs/helpers');
const { isPrivate } = require('../private-browsing/utils');

// A weak map of workers to hold private attributes that
// should not be exposed
const workers = new WeakMap();

let modelFor = (worker) => workers.get(worker);

const ERR_DESTROYED =
  "Couldn't find the worker to receive this message. " +
  "The script may not be initialized yet, or may already have been unloaded.";

const ERR_FROZEN = "The page is currently hidden and can no longer be used " +
                   "until it is visible again.";

/**
 * Message-passing facility for communication between code running
 * in the content and add-on process.
 * @see https://developer.mozilla.org/en-US/Add-ons/SDK/Low-Level_APIs/content_worker
 */
const Worker = Class({
  implements: [EventTarget],
  initialize: function WorkerConstructor (options) {
    // Save model in weak map to not expose properties
    let model = createModel();
    workers.set(this, model);

    options = options || {};

    if ('contentScriptFile' in options)
      this.contentScriptFile = options.contentScriptFile;
    if ('contentScriptOptions' in options)
      this.contentScriptOptions = options.contentScriptOptions;
    if ('contentScript' in options)
      this.contentScript = options.contentScript;
    if ('injectInDocument' in options)
      this.injectInDocument = !!options.injectInDocument;

    setListeners(this, options);

    unload.ensure(this, "destroy");

    // Ensure that worker.port is initialized for contentWorker to be able
    // to send events during worker initialization.
    this.port = createPort(this);

    model.documentUnload = documentUnload.bind(this);
    model.pageShow = pageShow.bind(this);
    model.pageHide = pageHide.bind(this);

    if ('window' in options)
      attach(this, options.window);
  },

  /**
   * Sends a message to the worker's global scope. Method takes single
   * argument, which represents data to be sent to the worker. The data may
   * be any primitive type value or `JSON`. Call of this method asynchronously
   * emits `message` event with data value in the global scope of this
   * symbiont.
   *
   * `message` event listeners can be set either by calling
   * `self.on` with a first argument string `"message"` or by
   * implementing `onMessage` function in the global scope of this worker.
   * @param {Number|String|JSON} data
   */
  postMessage: function (...data) {
    let model = modelFor(this);
    let args = ['message'].concat(data);
    if (!model.inited) {
      model.earlyEvents.push(args);
      return;
    }
    processMessage.apply(null, [this].concat(args));
  },

  get url () {
    let model = modelFor(this);
    // model.window will be null after detach
    return model.window ? model.window.document.location.href : null;
  },

  get contentURL () {
    let model = modelFor(this);
    return model.window ? model.window.document.URL : null;
  },

  get tab () {
    let model = modelFor(this);
    // model.window will be null after detach
    if (model.window)
      return getTabForWindow(model.window);
    return null;
  },

  // Implemented to provide some of the previous features of exposing sandbox
  // so that Worker can be extended
  getSandbox: function () {
    return modelFor(this).contentWorker;
  },

  toString: function () { return '[object Worker]'; },
  attach: method(attach),
  detach: method(detach),
  destroy: method(destroy)
});
exports.Worker = Worker;

attach.define(Worker, function (worker, window) {
  let model = modelFor(worker);
  model.window = window;
  // Track document unload to destroy this worker.
  // We can't watch for unload event on page's window object as it
  // prevents bfcache from working:
  // https://developer.mozilla.org/En/Working_with_BFCache
  model.windowID = getInnerId(model.window);
  events.on("inner-window-destroyed", model.documentUnload);

  // Listen to pagehide event in order to freeze the content script
  // while the document is frozen in bfcache:
  model.window.addEventListener("pageshow", model.pageShow, true);
  model.window.addEventListener("pagehide", model.pageHide, true);

  // will set model.contentWorker pointing to the private API:
  model.contentWorker = WorkerSandbox(worker, model.window);

  // Mainly enable worker.port.emit to send event to the content worker
  model.inited = true;
  model.frozen = false;

  // Fire off `attach` event
  emit(worker, 'attach', window);

  // Process all events and messages that were fired before the
  // worker was initialized.
  model.earlyEvents.forEach(args => processMessage.apply(null, [worker].concat(args)));
});

/**
 * Remove all internal references to the attached document
 * Tells _port to unload itself and removes all the references from itself.
 */
detach.define(Worker, function (worker, reason) {
  let model = modelFor(worker);

  // maybe unloaded before content side is created
  if (model.contentWorker) {
    model.contentWorker.destroy(reason);
  }

  model.contentWorker = null;
  if (model.window) {
    model.window.removeEventListener("pageshow", model.pageShow, true);
    model.window.removeEventListener("pagehide", model.pageHide, true);
  }
  model.window = null;
  // This method may be called multiple times,
  // avoid dispatching `detach` event more than once
  if (model.windowID) {
    model.windowID = null;
    events.off("inner-window-destroyed", model.documentUnload);
    model.earlyEvents.length = 0;
    emit(worker, 'detach');
  }
  model.inited = false;
});

isPrivate.define(Worker, ({ tab }) => isPrivate(tab));

/**
 * Tells content worker to unload itself and
 * removes all the references from itself.
 */
destroy.define(Worker, function (worker, reason) {
  detach(worker, reason);
  modelFor(worker).inited = true;
  // Specifying no type or listener removes all listeners
  // from target
  off(worker);
});

/**
 * Events fired by workers
 */
function documentUnload ({ subject, data }) {
  let model = modelFor(this);
  let innerWinID = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
  if (innerWinID != model.windowID) return false;
  detach(this);
  return true;
}

function pageShow () {
  let model = modelFor(this);
  model.contentWorker.emitSync('pageshow');
  emit(this, 'pageshow');
  model.frozen = false;
}

function pageHide () {
  let model = modelFor(this);
  model.contentWorker.emitSync('pagehide');
  emit(this, 'pagehide');
  model.frozen = true;
}

/**
 * Fired from postMessage and emitEventToContent, or from the earlyMessage
 * queue when fired before the content is loaded. Sends arguments to
 * contentWorker if able
 */

function processMessage (worker, ...args) {
  let model = modelFor(worker) || {};
  if (!model.contentWorker)
    throw new Error(ERR_DESTROYED);
  if (model.frozen)
    throw new Error(ERR_FROZEN);
  model.contentWorker.emit.apply(null, args);
}

function createModel () {
  return {
    // List of messages fired before worker is initialized
    earlyEvents: [],
    // Is worker connected to the content worker sandbox ?
    inited: false,
    // Is worker being frozen? i.e related document is frozen in bfcache.
    // Content script should not be reachable if frozen.
    frozen: true,
    /**
     * Reference to the content side of the worker.
     * @type {WorkerGlobalScope}
     */
    contentWorker: null,
    /**
     * Reference to the window that is accessible from
     * the content scripts.
     * @type {Object}
     */
    window: null
  };
}

function createPort (worker) {
  let port = EventTarget();
  port.emit = emitEventToContent.bind(null, worker);
  return port;
}

/**
 * Emit a custom event to the content script,
 * i.e. emit this event on `self.port`
 */
function emitEventToContent (worker, ...eventArgs) {
  let model = modelFor(worker);
  let args = ['event'].concat(eventArgs);
  if (!model.inited) {
    model.earlyEvents.push(args);
    return;
  }
  processMessage.apply(null, [worker].concat(args));
}
