/* 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 Contexts = require("./context");
const Readers = require("./readers");
const Component = require("../ui/component");
const { Class } = require("../core/heritage");
const { map, filter, object, reduce, keys, symbols,
        pairs, values, each, some, isEvery, count } = require("../util/sequence");
const { loadModule } = require("framescript/manager");
const { Cu, Cc, Ci } = require("chrome");
const prefs = require("sdk/preferences/service");

const globalMessageManager = Cc["@mozilla.org/globalmessagemanager;1"]
                              .getService(Ci.nsIMessageListenerManager);
const preferencesService = Cc["@mozilla.org/preferences-service;1"].
                            getService(Ci.nsIPrefService).
                            getBranch(null);


const readTable = Symbol("context-menu/read-table");
const nameTable = Symbol("context-menu/name-table");
const onContext = Symbol("context-menu/on-context");
const isMatching = Symbol("context-menu/matching-handler?");

exports.onContext = onContext;
exports.readTable = readTable;
exports.nameTable = nameTable;


const propagateOnContext = (item, data) =>
  each(child => child[onContext](data), item.state.children);

const isContextMatch = item => !item[isMatching] || item[isMatching]();

// For whatever reason addWeakMessageListener does not seems to work as our
// instance seems to dropped even though it's alive. This is simple workaround
// to avoid dead object excetptions.
const WeakMessageListener = function(receiver, handler="receiveMessage") {
  this.receiver = receiver
  this.handler = handler
};
WeakMessageListener.prototype = {
  constructor: WeakMessageListener,
  receiveMessage(message) {
    if (Cu.isDeadWrapper(this.receiver)) {
      message.target.messageManager.removeMessageListener(message.name, this);
    }
    else {
      this.receiver[this.handler](message);
    }
  }
};

const OVERFLOW_THRESH = "extensions.addon-sdk.context-menu.overflowThreshold";
const onMessage = Symbol("context-menu/message-listener");
const onPreferceChange = Symbol("context-menu/preference-change");
const ContextMenuExtension = Class({
  extends: Component,
  initialize: Component,
  setup() {
    const messageListener = new WeakMessageListener(this, onMessage);
    loadModule(globalMessageManager, "framescript/context-menu", true, "onContentFrame");
    globalMessageManager.addMessageListener("sdk/context-menu/read", messageListener);
    globalMessageManager.addMessageListener("sdk/context-menu/readers?", messageListener);

    preferencesService.addObserver(OVERFLOW_THRESH, this, false);
  },
  observe(_, __, name) {
    if (name === OVERFLOW_THRESH) {
      const overflowThreshold = prefs.get(OVERFLOW_THRESH, 10);
      this[Component.patch]({overflowThreshold});
    }
  },
  [onMessage]({name, data, target}) {
    if (name === "sdk/context-menu/read")
      this[onContext]({target, data});
    if (name === "sdk/context-menu/readers?")
      target.messageManager.sendAsyncMessage("sdk/context-menu/readers",
                                             JSON.parse(JSON.stringify(this.state.readers)));
  },
  [Component.initial](options={}, children) {
    const element = options.element || null;
    const target = options.target || null;
    const readers = Object.create(null);
    const users = Object.create(null);
    const registry = new WeakSet();
    const overflowThreshold = prefs.get(OVERFLOW_THRESH, 10);

    return { target, children: [], readers, users, element,
             registry, overflowThreshold };
  },
  [Component.isUpdated](before, after) {
    // Update only if target changed, since there is no point in re-rendering
    // when children are. Also new items added won't be in sync with a latest
    // context target so we should really just render before drawing context
    // menu.
    return before.target !== after.target;
  },
  [Component.render]({element, children, overflowThreshold}) {
    if (!element) return null;

    const items = children.filter(isContextMatch);
    const body = items.length === 0 ? items :
                 items.length < overflowThreshold ? [new Separator(),
                                                     ...items] :
                 [{tagName: "menu",
                   className: "sdk-context-menu-overflow-menu",
                   label: "Add-ons",
                   accesskey: "A",
                   children: [{tagName: "menupopup",
                               children: items}]}];
    return {
      element: element,
      tagName: "menugroup",
      style: "-moz-box-orient: vertical;",
      className: "sdk-context-menu-extension",
      children: body
    }
  },
  // Adds / remove child to it's own list.
  add(item) {
    this[Component.patch]({children: this.state.children.concat(item)});
  },
  remove(item) {
    this[Component.patch]({
      children: this.state.children.filter(x => x !== item)
    });
  },
  register(item) {
    const { users, registry } = this.state;
    if (registry.has(item)) return;
    registry.add(item);

    // Each (ContextHandler) item has a readTable that is a
    // map of keys to readers extracting them from the content.
    // During the registraction we update intrnal record of unique
    // readers and users per reader. Most context will have a reader
    // shared across all instances there for map of users per reader
    // is stored separately from the reader so that removing reader
    // will occur only when no users remain.
    const table = item[readTable];
    // Context readers store data in private symbols so we need to
    // collect both table keys and private symbols.
    const names = [...keys(table), ...symbols(table)];
    const readers = map(name => table[name], names);
    // Create delta for registered readers that will be merged into
    // internal readers table.
    const added = filter(x => !users[x.id], readers);
    const delta = object(...map(x => [x.id, x], added));

    const update = reduce((update, reader) => {
      const n = update[reader.id] || 0;
      update[reader.id] = n + 1;
      return update;
    }, Object.assign({}, users), readers);

    // Patch current state with a changes that registered item caused.
    this[Component.patch]({users: update,
                           readers: Object.assign(this.state.readers, delta)});

    if (count(added)) {
      globalMessageManager.broadcastAsyncMessage("sdk/context-menu/readers",
                                                 JSON.parse(JSON.stringify(delta)));
    }
  },
  unregister(item) {
    const { users, registry } = this.state;
    if (!registry.has(item)) return;
    registry.delete(item);

    const table = item[readTable];
    const names = [...keys(table), ...symbols(table)];
    const readers = map(name => table[name], names);
    const update = reduce((update, reader) => {
      update[reader.id] = update[reader.id] - 1;
      return update;
    }, Object.assign({}, users), readers);
    const removed = filter(id => !update[id], keys(update));
    const delta = object(...map(x => [x, null], removed));

    this[Component.patch]({users: update,
                           readers: Object.assign(this.state.readers, delta)});

    if (count(removed)) {
      globalMessageManager.broadcastAsyncMessage("sdk/context-menu/readers",
                                                 JSON.parse(JSON.stringify(delta)));
    }
  },

  [onContext]({data, target}) {
    propagateOnContext(this, data);
    const document = target.ownerDocument;
    const element = document.getElementById("contentAreaContextMenu");

    this[Component.patch]({target: data, element: element});
  }
});this,
exports.ContextMenuExtension = ContextMenuExtension;

// Takes an item options and
const makeReadTable = ({context, read}) => {
  // Result of this function is a tuple of all readers &
  // name, reader id pairs.

  // Filter down to contexts that have a reader associated.
  const contexts = filter(context => context.read, context);
  // Merge all contexts read maps to a single hash, note that there should be
  // no name collisions as context implementations expect to use private
  // symbols for storing it's read data.
  return Object.assign({}, ...map(({read}) => read, contexts), read);
}

const readTarget = (nameTable, data) =>
  object(...map(([name, id]) => [name, data[id]], nameTable))

const ContextHandler = Class({
  extends: Component,
  initialize: Component,
  get context() {
    return this.state.options.context;
  },
  get read() {
    return this.state.options.read;
  },
  [Component.initial](options) {
    return {
      table: makeReadTable(options),
      requiredContext: filter(context => context.isRequired, options.context),
      optionalContext: filter(context => !context.isRequired, options.context)
    }
  },
  [isMatching]() {
    const {target, requiredContext, optionalContext} = this.state;
    return isEvery(context => context.isCurrent(target), requiredContext) &&
            (count(optionalContext) === 0 ||
             some(context => context.isCurrent(target), optionalContext));
  },
  setup() {
    const table = makeReadTable(this.state.options);
    this[readTable] = table;
    this[nameTable] = [...map(symbol => [symbol, table[symbol].id], symbols(table)),
                       ...map(name => [name, table[name].id], keys(table))];


    contextMenu.register(this);

    each(child => contextMenu.remove(child), this.state.children);
    contextMenu.add(this);
  },
  dispose() {
    contextMenu.remove(this);

    each(child => contextMenu.unregister(child), this.state.children);
    contextMenu.unregister(this);
  },
  // Internal `Symbol("onContext")` method is invoked when "contextmenu" event
  // occurs in content process. Context handles with children delegate to each
  // child and patch it's internal state to reflect new contextmenu target.
  [onContext](data) {
    propagateOnContext(this, data);
    this[Component.patch]({target: readTarget(this[nameTable], data)});
  }
});
const isContextHandler = item => item instanceof ContextHandler;

exports.ContextHandler = ContextHandler;

const Menu = Class({
  extends: ContextHandler,
  [isMatching]() {
    return ContextHandler.prototype[isMatching].call(this) &&
           this.state.children.filter(isContextHandler)
                              .some(isContextMatch);
  },
  [Component.render]({children, options}) {
    const items = children.filter(isContextMatch);
    return {tagName: "menu",
            className: "sdk-context-menu menu-iconic",
            label: options.label,
            accesskey: options.accesskey,
            image: options.icon,
            children: [{tagName: "menupopup",
                        children: items}]};
  }
});
exports.Menu = Menu;

const onCommand = Symbol("context-menu/item/onCommand");
const Item = Class({
  extends: ContextHandler,
  get onClick() {
    return this.state.options.onClick;
  },
  [Component.render]({options}) {
    const {label, icon, accesskey} = options;
    return {tagName: "menuitem",
            className: "sdk-context-menu-item menuitem-iconic",
            label,
            accesskey,
            image: icon,
            oncommand: this};
  },
  handleEvent(event) {
    if (this.onClick)
      this.onClick(this.state.target);
  }
});
exports.Item = Item;

var Separator = Class({
  extends: Component,
  initialize: Component,
  [Component.render]() {
    return {tagName: "menuseparator",
            className: "sdk-context-menu-separator"}
  },
  [onContext]() {

  }
});
exports.Separator = Separator;

exports.Contexts = Contexts;
exports.Readers = Readers;

const createElement = (vnode, {document}) => {
   const node = vnode.namespace ?
              document.createElementNS(vnode.namespace, vnode.tagName) :
              document.createElement(vnode.tagName);

   node.setAttribute("data-component-path", vnode[Component.path]);

   each(([key, value]) => {
     if (key === "tagName") {
       return;
     }
     if (key === "children") {
       return;
     }

     if (key.startsWith("on")) {
       node.addEventListener(key.substr(2), value)
       return;
     }

     if (typeof(value) !== "object" &&
         typeof(value) !== "function" &&
         value !== void(0) &&
         value !== null)
    {
       if (key === "className") {
         node[key] = value;
       }
       else {
         node.setAttribute(key, value);
       }
       return;
     }
   }, pairs(vnode));

  each(child => node.appendChild(createElement(child, {document})), vnode.children);
  return node;
};

const htmlWriter = tree => {
  if (tree !== null) {
    const root = tree.element;
    const node = createElement(tree, {document: root.ownerDocument});
    const before = root.querySelector("[data-component-path='/']");
    if (before) {
      root.replaceChild(node, before);
    } else {
      root.appendChild(node);
    }
  }
};


const contextMenu = ContextMenuExtension();
exports.contextMenu = contextMenu;
Component.mount(contextMenu, htmlWriter);