/* -*- indent-tabs-mode: nil; js-indent-level: 2; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* 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 events = require("sdk/event/core");
const protocol = require("devtools/server/protocol");

const { Cu, Ci } = require("chrome");

const { on, once, off, emit } = events;
const { method, Arg, Option, RetVal, types } = protocol;

const { sandbox, evaluate } = require('sdk/loader/sandbox');
const { Class } = require("sdk/core/heritage");

const { PlainTextConsole } = require('sdk/console/plain-text');

const { DirectorRegistry } = require("./director-registry");

/**
 * Error Messages
 */

const ERR_MESSAGEPORT_FINALIZED = "message port finalized";

const ERR_DIRECTOR_UNKNOWN_SCRIPTID = "unkown director-script id";
const ERR_DIRECTOR_UNINSTALLED_SCRIPTID = "uninstalled director-script id";

/**
 * Type describing a messageport event
 */
types.addDictType("messageportevent", {
  isTrusted: "boolean",
  data: "nullable:primitive",
  origin: "nullable:string",
  lastEventId: "nullable:string",
  source: "messageport",
  ports: "nullable:array:messageport"
});

/**
 * A MessagePort Actor allowing communication through messageport events
 * over the remote debugging protocol.
 */
var MessagePortActor = exports.MessagePortActor = protocol.ActorClass({
  typeName: "messageport",

  /**
   * Create a MessagePort actor.
   *
   * @param DebuggerServerConnection conn
   *        The server connection.
   * @param MessagePort port
   *        The wrapped MessagePort.
   */
  initialize: function(conn, port) {
    protocol.Actor.prototype.initialize.call(this, conn);

    // NOTE: can't get a weak reference because we need to subscribe events
    // using port.onmessage or addEventListener
    this.port = port;
  },

  destroy: function(conn) {
    protocol.Actor.prototype.destroy.call(this, conn);
    this.finalize();
  },

  /**
   * Sends a message on the wrapped message port.
   *
   * @param Object msg
   *        The JSON serializable message event payload
   */
  postMessage: method(function (msg) {
    if (!this.port) {
      console.error(ERR_MESSAGEPORT_FINALIZED);
      return;
    }

    this.port.postMessage(msg);
  }, {
    oneway: true,
    request: {
      msg: Arg(0, "nullable:json")
    }
  }),

  /**
   * Starts to receive and send queued messages on this message port.
   */
  start: method(function () {
    if (!this.port) {
      console.error(ERR_MESSAGEPORT_FINALIZED);
      return;
    }

    // NOTE: set port.onmessage to a function is an implicit start
    // and starts to send queued messages.
    // On the client side we should set MessagePortClient.onmessage
    // to a setter which register an handler to the message event
    // and call the actor start method to start receiving messages
    // from the MessagePort's queue.
    this.port.onmessage = (evt) => {
      var ports;

      // TODO: test these wrapped ports
      if (Array.isArray(evt.ports)) {
        ports = evt.ports.map((port) => {
          let actor = new MessagePortActor(this.conn, port);
          this.manage(actor);
          return actor;
        });
      }

      emit(this, "message", {
        isTrusted: evt.isTrusted,
        data: evt.data,
        origin: evt.origin,
        lastEventId: evt.lastEventId,
        source: this,
        ports: ports
      });
    };
  }, {
    oneway: true,
    request: {}
  }),

  /**
   * Starts to receive and send queued messages on this message port, or
   * raise an exception if the port is null
   */
  close: method(function () {
    if (!this.port) {
      console.error(ERR_MESSAGEPORT_FINALIZED);
      return;
    }

    this.port.onmessage = null;
    this.port.close();
  }, {
    oneway: true,
    request: {}
  }),

  finalize: method(function () {
    this.close();
    this.port = null;
  }, {
    oneway: true
  }),

  /**
   * Events emitted by this actor.
   */
  events: {
    "message": {
      type: "message",
      msg: Arg(0, "nullable:messageportevent")
    }
  }
});

/**
 * The corresponding Front object for the MessagePortActor.
 */
var MessagePortFront = exports.MessagePortFront = protocol.FrontClass(MessagePortActor, {
  initialize: function (client, form) {
    protocol.Front.prototype.initialize.call(this, client, form);
  }
});


/**
 * Type describing a director-script error
 */
types.addDictType("director-script-error", {
  directorScriptId: "string",
  message: "nullable:string",
  stack: "nullable:string",
  fileName: "nullable:string",
  lineNumber: "nullable:number",
  columnNumber: "nullable:number"
});

/**
 * Type describing a director-script attach event
 */
types.addDictType("director-script-attach", {
  directorScriptId: "string",
  url: "string",
  innerId: "number",
  port: "nullable:messageport"
});

/**
 * Type describing a director-script detach event
 */
types.addDictType("director-script-detach", {
  directorScriptId: "string",
  innerId: "number"
});

/**
 * The Director Script Actor manage javascript code running in a non-privileged sandbox with the same
 * privileges of the target global (browser tab or a firefox os app).
 *
 * After retrieving an instance of this actor (from the tab director actor), you'll need to set it up
 * by calling setup().
 *
 * After the setup, this actor will automatically attach/detach the content script (and optionally a
 * directly connect the debugger client and the content script using a MessageChannel) on tab
 * navigation.
 */
var DirectorScriptActor = exports.DirectorScriptActor = protocol.ActorClass({
  typeName: "director-script",

  /**
   * Events emitted by this actor.
   */
  events: {
    "error": {
      type: "error",
      data: Arg(0, "director-script-error")
    },
    "attach": {
      type: "attach",
      data: Arg(0, "director-script-attach")
    },
    "detach": {
      type: "detach",
      data: Arg(0, "director-script-detach")
    }
  },

  /**
   * Creates the director script actor
   *
   * @param DebuggerServerConnection conn
   *        The server connection.
   * @param Actor tabActor
   *        The tab (or root) actor.
   * @param String scriptId
   *        The director-script id.
   * @param String scriptCode
   *        The director-script javascript source.
   * @param Object scriptOptions
   *        The director-script options object.
   */
  initialize: function(conn, tabActor, { scriptId, scriptCode, scriptOptions }) {
    protocol.Actor.prototype.initialize.call(this, conn, tabActor);

    this.tabActor = tabActor;

    this._scriptId = scriptId;
    this._scriptCode = scriptCode;
    this._scriptOptions = scriptOptions;
    this._setupCalled = false;

    this._onGlobalCreated   = this._onGlobalCreated.bind(this);
    this._onGlobalDestroyed = this._onGlobalDestroyed.bind(this);
  },
  destroy: function(conn) {
    protocol.Actor.prototype.destroy.call(this, conn);

    this.finalize();
  },

  /**
   * Starts listening to the tab global created, in order to create the director-script sandbox
   * using the configured scriptCode, attached/detached automatically to the tab
   * window on tab navigation.
   *
   * @param Boolean reload
   *        attach the page immediately or reload it first.
   * @param Boolean skipAttach
   *        skip the attach
   */
  setup: method(function ({ reload, skipAttach }) {
    if (this._setupCalled) {
      // do nothing
      return;
    }

    this._setupCalled = true;

    on(this.tabActor, "window-ready", this._onGlobalCreated);
    on(this.tabActor, "window-destroyed", this._onGlobalDestroyed);

    // optional skip attach (needed by director-manager for director scripts bulk activation)
    if (skipAttach) {
      return;
    }

    if (reload) {
      this.window.location.reload();
    } else {
      // fake a global created event to attach without reload
      this._onGlobalCreated({ id: getWindowID(this.window), window: this.window, isTopLevel: true });
    }
  }, {
    request: {
      reload: Option(0, "boolean"),
      skipAttach: Option(0, "boolean")
    },
    oneway: true
  }),

  /**
   * Get the attached MessagePort actor if any
   */
  getMessagePort: method(function () {
    return this._messagePortActor;
  }, {
    request: { },
    response: {
      port: RetVal("nullable:messageport")
    }
  }),

  /**
   * Stop listening for document global changes, destroy the content worker and puts
   * this actor to hibernation.
   */
  finalize: method(function () {
    if (!this._setupCalled) {
      return;
    }

    off(this.tabActor, "window-ready", this._onGlobalCreated);
    off(this.tabActor, "window-destroyed", this._onGlobalDestroyed);

    this._onGlobalDestroyed({ id: this._lastAttachedWinId });

    this._setupCalled = false;
  }, {
    oneway: true
  }),

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

  /* event handlers */
  _onGlobalCreated: function({ id, window, isTopLevel }) {
    if (!isTopLevel) {
      // filter iframes
      return;
    }

    try {
      if (this._lastAttachedWinId) {
        // if we have received a global created without a previous global destroyed,
        // it's time to cleanup the previous state
        this._onGlobalDestroyed(this._lastAttachedWinId);
      }

      // TODO: check if we want to share a single sandbox per global
      //       for multiple debugger clients

      // create & attach the new sandbox
      this._scriptSandbox = new DirectorScriptSandbox({
        scriptId: this._scriptId,
        scriptCode: this._scriptCode,
        scriptOptions: this._scriptOptions
      });

      // attach the global window
      this._lastAttachedWinId = id;
      var port = this._scriptSandbox.attach(window, id);
      this._onDirectorScriptAttach(window, port);
    } catch(e) {
      this._onDirectorScriptError(e);
    }
  },
  _onGlobalDestroyed: function({ id }) {
     if (id !== this._lastAttachedWinId) {
       // filter destroyed globals
       return;
     }

     // unmanage and cleanup the messageport actor
     if (this._messagePortActor) {
       this.unmanage(this._messagePortActor);
       this._messagePortActor = null;
     }

     // NOTE: destroy here the old worker
     if (this._scriptSandbox) {
       this._scriptSandbox.destroy(this._onDirectorScriptError.bind(this));

       // send a detach event to the debugger client
       emit(this, "detach", {
         directorScriptId: this._scriptId,
         innerId: this._lastAttachedWinId
       });

       this._lastAttachedWinId = null;
       this._scriptSandbox = null;
     }
  },
  _onDirectorScriptError: function(error) {
    // route the content script error to the debugger client
    if (error) {
      // prevents silent director-script-errors
      console.error("director-script-error", error);
      // route errors to debugger server clients
      emit(this, "error", {
        directorScriptId: this._scriptId,
        message: error.toString(),
        stack: error.stack,
        fileName: error.fileName,
        lineNumber: error.lineNumber,
        columnNumber: error.columnNumber
      });
    }
  },
  _onDirectorScriptAttach: function(window, port) {
    let portActor = new MessagePortActor(this.conn, port);
    this.manage(portActor);
    this._messagePortActor = portActor;

    emit(this, "attach", {
      directorScriptId: this._scriptId,
      url: (window && window.location) ? window.location.toString() : "",
      innerId: this._lastAttachedWinId,
      port: this._messagePortActor
    });
  }
});

/**
 * The corresponding Front object for the DirectorScriptActor.
 */
var DirectorScriptFront = exports.DirectorScriptFront = protocol.FrontClass(DirectorScriptActor, {
  initialize: function (client, form) {
    protocol.Front.prototype.initialize.call(this, client, form);
  }
});

/**
 * The DirectorManager Actor is a tab actor which manages enabling/disabling director scripts.
 */
const DirectorManagerActor = exports.DirectorManagerActor = protocol.ActorClass({
  typeName: "director-manager",

  /**
   * Events emitted by this actor.
   */
  events: {
    "director-script-error": {
      type: "error",
      data: Arg(0, "director-script-error")
    },
    "director-script-attach": {
      type: "attach",
      data: Arg(0, "director-script-attach")
    },
    "director-script-detach": {
      type: "detach",
      data: Arg(0, "director-script-detach")
    }
  },

  /* init & destroy methods */
  initialize: function(conn, tabActor) {
    protocol.Actor.prototype.initialize.call(this, conn);
    this.tabActor = tabActor;
    this._directorScriptActorsMap = new Map();
  },
  destroy: function(conn) {
    protocol.Actor.prototype.destroy.call(this, conn);
    this.finalize();
  },

  /**
   * Retrieves the list of installed director-scripts.
   */
  list: method(function () {
    let enabledScriptIds = [...this._directorScriptActorsMap.keys()];

    return {
      installed: DirectorRegistry.list(),
      enabled: enabledScriptIds
    };
  }, {
    response: {
      directorScripts: RetVal("json")
    }
  }),

  /**
   * Bulk enabling director-scripts.
   *
   * @param Array[String] selectedIds
   *        The list of director-script ids to be enabled,
   *        ["*"] will activate all the installed director-scripts
   * @param Boolean reload
   *        optionally reload the target window
   */
  enableByScriptIds: method(function(selectedIds, { reload }) {
    if (selectedIds && selectedIds.length === 0) {
      // filtered all director scripts ids
      return;
    }

    for (let scriptId of DirectorRegistry.list()) {
      // filter director script ids
      if (selectedIds.indexOf("*") < 0 &&
          selectedIds.indexOf(scriptId) < 0) {
        continue;
      }

      let actor = this.getByScriptId(scriptId);

      // skip attach if reload is true (activated director scripts
      // will be automatically attached on the final reload)
      actor.setup({ reload: false, skipAttach: reload });
    }

    if (reload) {
      this.tabActor.window.location.reload();
    }
  }, {
    oneway: true,
    request: {
      selectedIds: Arg(0, "array:string"),
      reload: Option(1, "boolean")
    }
  }),

  /**
   * Bulk disabling director-scripts.
   *
   * @param Array[String] selectedIds
   *        The list of director-script ids to be disable,
   *        ["*"] will de-activate all the enable director-scripts
   * @param Boolean reload
   *        optionally reload the target window
   */
  disableByScriptIds: method(function(selectedIds, { reload }) {
    if (selectedIds && selectedIds.length === 0) {
      // filtered all director scripts ids
      return;
    }

    for (let scriptId of this._directorScriptActorsMap.keys()) {
      // filter director script ids
      if (selectedIds.indexOf("*") < 0 &&
          selectedIds.indexOf(scriptId) < 0) {
        continue;
      }

      let actor = this._directorScriptActorsMap.get(scriptId);
      this._directorScriptActorsMap.delete(scriptId);

      // finalize the actor (which will produce director-script-detach event)
      actor.finalize();
      // unsubscribe event handlers on the disabled actor
      off(actor);

      this.unmanage(actor);
    }

    if (reload) {
      this.tabActor.window.location.reload();
    }
  }, {
    oneway: true,
    request: {
      selectedIds: Arg(0, "array:string"),
      reload: Option(1, "boolean")
    }
  }),

  /**
   * Retrieves the actor instance of an installed director-script
   * (and create the actor instance if it doesn't exists yet).
   */
  getByScriptId: method(function(scriptId) {
    var id = scriptId;
    // raise an unknown director-script id exception
    if (!DirectorRegistry.checkInstalled(id)) {
      console.error(ERR_DIRECTOR_UNKNOWN_SCRIPTID, id);
      throw Error(ERR_DIRECTOR_UNKNOWN_SCRIPTID);
    }

    // get a previous created actor instance
    let actor = this._directorScriptActorsMap.get(id);

    // create a new actor instance
    if (!actor) {
      let directorScriptDefinition = DirectorRegistry.get(id);

      // test lazy director-script (e.g. uninstalled in the parent process)
      if (!directorScriptDefinition) {

        console.error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID, id);
        throw Error(ERR_DIRECTOR_UNINSTALLED_SCRIPTID);
      }

      actor = new DirectorScriptActor(this.conn, this.tabActor, directorScriptDefinition);
      this._directorScriptActorsMap.set(id, actor);

      on(actor, "error", emit.bind(null, this, "director-script-error"));
      on(actor, "attach", emit.bind(null, this, "director-script-attach"));
      on(actor, "detach", emit.bind(null, this, "director-script-detach"));

      this.manage(actor);
    }

    return actor;
  }, {
    request: {
      scriptId: Arg(0, "string")
    },
    response: {
      directorScript: RetVal("director-script")
    }
  }),

  finalize: method(function() {
    this.disableByScriptIds(["*"], false);
  }, {
    oneway: true
  })
});

/**
 * The corresponding Front object for the DirectorManagerActor.
 */
exports.DirectorManagerFront = protocol.FrontClass(DirectorManagerActor, {
  initialize: function(client, { directorManagerActor }) {
    protocol.Front.prototype.initialize.call(this, client, {
      actor: directorManagerActor
    });
    this.manage(this);
  }
});

/* private helpers */

/**
 * DirectorScriptSandbox is a private utility class, which attach a non-priviliged sandbox
 * to a target window.
 */
const DirectorScriptSandbox = Class({
  initialize: function({scriptId, scriptCode, scriptOptions}) {
    this._scriptId = scriptId;
    this._scriptCode = scriptCode;
    this._scriptOptions = scriptOptions;
  },

  attach: function(window, innerId) {
    this._innerId = innerId,
    this._window = window;
    this._proto = Cu.createObjectIn(this._window);

    var id = this._scriptId;
    var uri = this._scriptCode;

    this._sandbox = sandbox(window, {
      sandboxName: uri,
      sandboxPrototype: this._proto,
      sameZoneAs: window,
      wantXrays: true,
      wantComponents: false,
      wantExportHelpers: false,
      metadata: {
        URI: uri,
        addonID: id,
        SDKDirectorScript: true,
        "inner-window-id": innerId
      }
    });

    // create a CommonJS module object which match the interface from addon-sdk
    // (addon-sdk/sources/lib/toolkit/loader.js#L678-L686)
    var module = Cu.cloneInto(Object.create(null, {
      id: { enumerable: true, value: id },
      uri: { enumerable: true, value: uri },
      exports: { enumerable: true, value: Cu.createObjectIn(this._sandbox) }
    }), this._sandbox);

    // create a console API object
    let directorScriptConsole = new PlainTextConsole(null, this._innerId);

    // inject CommonJS module globals into the sandbox prototype
    Object.defineProperties(this._proto, {
      module: { enumerable: true, value: module },
      exports: { enumerable: true, value: module.exports },
      console: {
        enumerable: true,
        value: Cu.cloneInto(directorScriptConsole, this._sandbox, { cloneFunctions: true })
      }
    });

    Object.defineProperties(this._sandbox, {
      require: {
        enumerable: true,
        value: Cu.cloneInto(function() {
          throw Error("NOT IMPLEMENTED");
        }, this._sandbox, { cloneFunctions: true })
      }
    });

    // TODO: if the debugger target is local, the debugger client could pass
    // to the director actor the resource url instead of the entire javascript source code.

    // evaluate the director script source in the sandbox
    evaluate(this._sandbox, this._scriptCode, 'javascript:' + this._scriptCode);

    // prepare the messageport connected to the debugger client
    let { port1, port2 } = new this._window.MessageChannel();

    // prepare the unload callbacks queue
    var sandboxOnUnloadQueue = this._sandboxOnUnloadQueue = [];

    // create the attach options
    var attachOptions = this._attachOptions = Cu.createObjectIn(this._sandbox);
    Object.defineProperties(attachOptions, {
      port: { enumerable: true, value: port1 },
      window: { enumerable: true, value: window },
      scriptOptions: { enumerable: true, value: Cu.cloneInto(this._scriptOptions, this._sandbox) },
      onUnload: {
        enumerable: true,
        value: Cu.cloneInto(function (cb) {
          // collect unload callbacks
          if (typeof cb == "function") {
            sandboxOnUnloadQueue.push(cb);
          }
        }, this._sandbox, { cloneFunctions: true })
      }
    });

    // select the attach method
    var exports = this._proto.module.exports;
    if (this._scriptOptions && "attachMethod" in this._scriptOptions) {
      this._sandboxOnAttach = exports[this._scriptOptions.attachMethod];
    } else {
      this._sandboxOnAttach = exports;
    }

    if (typeof this._sandboxOnAttach !== "function") {
      throw Error("the configured attachMethod '" +
                  (this._scriptOptions.attachMethod || "module.exports") +
                  "' is not exported by the directorScript");
    }

    // call the attach method
    this._sandboxOnAttach.call(this._sandbox, attachOptions);

    return port2;
  },
  destroy:  function(onError) {
    // evaluate queue unload methods if any
    while(this._sandboxOnUnloadQueue && this._sandboxOnUnloadQueue.length > 0) {
      let cb = this._sandboxOnUnloadQueue.pop();

      try {
        cb();
      } catch(e) {
        console.error("Exception on DirectorScript Sandbox destroy", e);
        onError(e);
      }
    }

    Cu.nukeSandbox(this._sandbox);
  }
});

function getWindowID(window) {
  return window.QueryInterface(Ci.nsIInterfaceRequestor)
               .getInterface(Ci.nsIDOMWindowUtils)
               .currentInnerWindowID;
}
