/* 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 { isChildLoader } = require('./core');
if (!isChildLoader)
  throw new Error("Cannot load sdk/remote/child in a main process loader.");

const { Ci, Cc } = require('chrome');
const runtime = require('../system/runtime');
const { Class } = require('../core/heritage');
const { Namespace } = require('../core/namespace');
const { omit } = require('../util/object');
const { when } = require('../system/unload');
const { EventTarget } = require('../event/target');
const { emit } = require('../event/core');
const { Disposable } = require('../core/disposable');
const { EventParent } = require('./utils');
const { addListItem, removeListItem } = require('../util/list');

const loaderID = require('@loader/options').loaderID;

const MAIN_PROCESS = Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;

const mm = Cc['@mozilla.org/childprocessmessagemanager;1'].
           getService(Ci.nsISyncMessageSender);

const ns = Namespace();

const process = {
  port: new EventTarget(),
  get id() {
    return runtime.processID;
  },
  get isRemote() {
    return runtime.processType != MAIN_PROCESS;
  }
};
exports.process = process;

process.port.emit = (...args) => {
  mm.sendAsyncMessage('sdk/remote/process/message', {
    loaderID,
    args
  });
}

function processMessageReceived({ data }) {
  // Ignore messages from other loaders
  if (data.loaderID != loaderID)
    return;
  let [event, ...args] = data.args;
  emit(process.port, event, process, ...args);
}

mm.addMessageListener('sdk/remote/process/message', processMessageReceived);

when(() => {
  mm.removeMessageListener('sdk/remote/process/message', processMessageReceived);
  frames = null;
});

process.port.on('sdk/remote/require', (process, uri) => {
  require(uri);
});

function listenerEquals(a, b) {
  for (let prop of ["type", "callback", "isCapturing"]) {
    if (a[prop] != b[prop])
      return false;
  }
  return true;
}

function listenerFor(type, callback, isCapturing = false) {
  return {
    type,
    callback,
    isCapturing,
    registeredCallback: undefined,
    get args() {
      return [
        this.type,
        this.registeredCallback ? this.registeredCallback : this.callback,
        this.isCapturing
      ];
    }
  };
}

function removeListenerFromArray(array, listener) {
  let index = array.findIndex(l => listenerEquals(l, listener));
  if (index < 0)
    return;
  array.splice(index, 1);
}

function getListenerFromArray(array, listener) {
  return array.find(l => listenerEquals(l, listener));
}

function arrayContainsListener(array, listener) {
  return !!getListenerFromArray(array, listener);
}

function makeFrameEventListener(frame, callback) {
  return callback.bind(frame);
}

let FRAME_ID = 0;
let tabMap = new Map();

function frameMessageReceived({ data }) {
  if (data.loaderID != loaderID)
    return;
  let [event, ...args] = data.args;
  emit(this.port, event, this, ...args);
}

const Frame = Class({
  implements: [ Disposable ],
  extends: EventTarget,
  setup: function(contentFrame) {
    // This ID should be unique for this loader across all processes
    ns(this).id = runtime.processID + ":" + FRAME_ID++;

    ns(this).contentFrame = contentFrame;
    ns(this).messageManager = contentFrame;
    ns(this).domListeners = [];

    tabMap.set(contentFrame.docShell, this);

    ns(this).messageReceived = frameMessageReceived.bind(this);
    ns(this).messageManager.addMessageListener('sdk/remote/frame/message', ns(this).messageReceived);

    this.port = new EventTarget();
    this.port.emit = (...args) => {
      ns(this).messageManager.sendAsyncMessage('sdk/remote/frame/message', {
        loaderID,
        args
      });
    };

    ns(this).messageManager.sendAsyncMessage('sdk/remote/frame/attach', {
      loaderID,
      frameID: ns(this).id,
      processID: runtime.processID
    });

    frames.attachItem(this);
  },

  dispose: function() {
    emit(this, 'detach', this);

    for (let listener of ns(this).domListeners)
      ns(this).contentFrame.removeEventListener(...listener.args);

    ns(this).messageManager.removeMessageListener('sdk/remote/frame/message', ns(this).messageReceived);
    tabMap.delete(ns(this).contentFrame.docShell);
    ns(this).contentFrame = null;
  },

  get content() {
    return ns(this).contentFrame.content;
  },

  get isTab() {
    let docShell = ns(this).contentFrame.docShell;
    if (process.isRemote) {
      // We don't want to roundtrip to the main process to get this property.
      // This hack relies on the host app having defined webBrowserChrome only
      // in frames that are part of the tabs. Since only Firefox has remote
      // processes right now and does this this works.
      let tabchild = docShell.QueryInterface(Ci.nsIInterfaceRequestor)
                             .getInterface(Ci.nsITabChild);
      return !!tabchild.webBrowserChrome;
    }
    else {
      // This is running in the main process so we can break out to the browser
      // And check we can find a tab for the browser element directly.
      let browser = docShell.chromeEventHandler;
      let tab = require('../tabs/utils').getTabForBrowser(browser);
      return !!tab;
    }
  },

  addEventListener: function(...args) {
    let listener = listenerFor(...args);
    if (arrayContainsListener(ns(this).domListeners, listener))
      return;

    listener.registeredCallback = makeFrameEventListener(this, listener.callback);

    ns(this).domListeners.push(listener);
    ns(this).contentFrame.addEventListener(...listener.args);
  },

  removeEventListener: function(...args) {
    let listener = getListenerFromArray(ns(this).domListeners, listenerFor(...args));
    if (!listener)
      return;

    removeListenerFromArray(ns(this).domListeners, listener);
    ns(this).contentFrame.removeEventListener(...listener.args);
  }
});

const FrameList = Class({
  implements: [ EventParent, Disposable ],
  extends: EventTarget,
  setup: function() {
    EventParent.prototype.initialize.call(this);

    this.port = new EventTarget();
    ns(this).domListeners = [];

    this.on('attach', frame => {
      for (let listener of ns(this).domListeners)
        frame.addEventListener(...listener.args);
    });
  },

  dispose: function() {
    // The only case where we get destroyed is when the loader is unloaded in
    // which case each frame will clean up its own event listeners.
    ns(this).domListeners = null;
  },

  getFrameForWindow: function(window) {
    for (let frame of this) {
      if (frame.content == window)
        return frame;
    }

    return null;
  },

  addEventListener: function(...args) {
    let listener = listenerFor(...args);
    if (arrayContainsListener(ns(this).domListeners, listener))
      return;

    ns(this).domListeners.push(listener);
    for (let frame of this)
      frame.addEventListener(...listener.args);
  },

  removeEventListener: function(...args) {
    let listener = listenerFor(...args);
    if (!arrayContainsListener(ns(this).domListeners, listener))
      return;

    removeListenerFromArray(ns(this).domListeners, listener);
    for (let frame of this)
      frame.removeEventListener(...listener.args);
  }
});
let frames = exports.frames = new FrameList();

function registerContentFrame(contentFrame) {
  let frame = new Frame(contentFrame);
}
exports.registerContentFrame = registerContentFrame;

function unregisterContentFrame(contentFrame) {
  let frame = tabMap.get(contentFrame.docShell);
  if (!frame)
    return;

  frame.destroy();
}
exports.unregisterContentFrame = unregisterContentFrame;
