/* 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",
  "engines": {
    "Firefox": "*",
    "SeaMonkey": "*"
  }
};

/*
 * Requiring hosts so they can subscribe to client messages
 */
require('./host/host-bookmarks');
require('./host/host-tags');
require('./host/host-query');

const { Cc, Ci } = require('chrome');
const { Class } = require('../core/heritage');
const { send } = require('../addon/events');
const { defer, reject, all, resolve, promised } = require('../core/promise');
const { EventTarget } = require('../event/target');
const { emit } = require('../event/core');
const { identity, defer:async } = require('../lang/functional');
const { extend, merge } = require('../util/object');
const { fromIterator } = require('../util/array');
const {
  constructTree, fetchItem, createQuery,
  isRootGroup, createQueryOptions
} = require('./utils');
const {
  bookmarkContract, groupContract, separatorContract
} = require('./contract');
const bmsrv = Cc["@mozilla.org/browser/nav-bookmarks-service;1"].
                getService(Ci.nsINavBookmarksService);

/*
 * Mapping of uncreated bookmarks with their created
 * counterparts
 */
const itemMap = new WeakMap();

/*
 * Constant used by nsIHistoryQuery; 1 is a bookmark query
 * https://developer.mozilla.org/en-US/docs/XPCOM_Interface_Reference/nsINavHistoryQueryOptions
 */
const BOOKMARK_QUERY = 1;

/*
 * Bookmark Item classes
 */

const Bookmark = Class({
  extends: [
    bookmarkContract.properties(identity)
  ],
  initialize: function initialize (options) {
    merge(this, bookmarkContract(extend(defaults, options)));
  },
  type: 'bookmark',
  toString: function () '[object Bookmark]'
});
exports.Bookmark = Bookmark;

const Group = Class({
  extends: [
    groupContract.properties(identity)
  ],
  initialize: function initialize (options) {
    // Don't validate if root group
    if (isRootGroup(options))
      merge(this, options);
    else
      merge(this, groupContract(extend(defaults, options)));
  },
  type: 'group',
  toString: function () '[object Group]'
});
exports.Group = Group;

const Separator = Class({
  extends: [
    separatorContract.properties(identity)
  ],
  initialize: function initialize (options) {
    merge(this, separatorContract(extend(defaults, options)));
  },
  type: 'separator',
  toString: function () '[object Separator]'
});
exports.Separator = Separator;

/*
 * Functions
 */

function save (items, options) {
  items = [].concat(items);
  options = options || {};
  let emitter = EventTarget();
  let results = [];
  let errors = [];
  let root = constructTree(items);
  let cache = new Map();

  let isExplicitSave = item => !!~items.indexOf(item);
  // `walk` returns an aggregate promise indicating the completion
  // of the `commitItem` on each node, not whether or not that
  // commit was successful

  // Force this to be async, as if a ducktype fails validation,
  // the promise implementation will fire an error event, which will
  // not trigger the handler as it's not yet bound
  //
  // Can remove after `Promise.jsm` is implemented in Bug 881047,
  // which will guarantee next tick execution
  async(() => root.walk(preCommitItem).then(commitComplete))();

  function preCommitItem ({value:item}) {
    // Do nothing if tree root, default group (unsavable),
    // or if it's a dependency and not explicitly saved (in the list
    // of items to be saved), and not needed to be saved
    if (item === null || // node is the tree root
        isRootGroup(item) ||
        (getId(item) && !isExplicitSave(item)))
      return;

    return promised(validate)(item)
      .then(() => commitItem(item, options))
      .then(data => construct(data, cache))
      .then(savedItem => {
        // If item was just created, make a map between
        // the creation object and created object,
        // so we can reference the item that doesn't have an id
        if (!getId(item))
          saveId(item, savedItem.id);

        // Emit both the processed item, and original item
        // so a mapping can be understood in handler
        emit(emitter, 'data', savedItem, item);
       
        // Push to results iff item was explicitly saved
        if (isExplicitSave(item))
          results[items.indexOf(item)] = savedItem;
      }, reason => {
        // Force reason to be a string for consistency
        reason = reason + '';
        // Emit both the reason, and original item
        // so a mapping can be understood in handler
        emit(emitter, 'error', reason + '', item);
        // Store unsaved item in results list
        results[items.indexOf(item)] = item;
        errors.push(reason);
      });
  }

  // Called when traversal of the node tree is completed and all
  // items have been committed
  function commitComplete () {
    emit(emitter, 'end', results);
  }

  return emitter;
}
exports.save = save;

function search (queries, options) {
  queries = [].concat(queries);
  let emitter = EventTarget();
  let cache = new Map();
  let queryObjs = queries.map(createQuery.bind(null, BOOKMARK_QUERY));
  let optionsObj = createQueryOptions(BOOKMARK_QUERY, options);

  // Can remove after `Promise.jsm` is implemented in Bug 881047,
  // which will guarantee next tick execution
  async(() => {
    send('sdk-places-query', { queries: queryObjs, options: optionsObj })
      .then(handleQueryResponse);
  })();
    
  function handleQueryResponse (data) {
    let deferreds = data.map(item => {
      return construct(item, cache).then(bookmark => {
        emit(emitter, 'data', bookmark);
        return bookmark;
      }, reason => {
        emit(emitter, 'error', reason);
        errors.push(reason);
      });
    });

    all(deferreds).then(data => {
      emit(emitter, 'end', data);
    }, () => emit(emitter, 'end', []));
  }

  return emitter;
}
exports.search = search;

function remove (items) {
  return [].concat(items).map(item => {
    item.remove = true;
    return item;
  });
}

exports.remove = remove;

/*
 * Internal Utilities
 */

function commitItem (item, options) {
  // Get the item's ID, or getId it's saved version if it exists
  let id = getId(item);
  let data = normalize(item);
  let promise;

  data.id = id;

  if (!id) {
    promise = send('sdk-places-bookmarks-create', data);
  } else if (item.remove) {
    promise = send('sdk-places-bookmarks-remove', { id: id });
  } else {
    promise = send('sdk-places-bookmarks-last-updated', {
      id: id
    }).then(function (updated) {
      // If attempting to save an item that is not the 
      // latest snapshot of a bookmark item, execute
      // the resolution function
      if (updated !== item.updated && options.resolve)
        return fetchItem(id)
          .then(options.resolve.bind(null, data));
      else
        return data;
    }).then(send.bind(null, 'sdk-places-bookmarks-save'));
  }

  return promise;
}

/*
 * Turns a bookmark item into a plain object,
 * converts `tags` from Set to Array, group instance to an id
 */
function normalize (item) {
  let data = merge({}, item);
  // Circumvent prototype property of `type`
  delete data.type;
  data.type = item.type;
  data.tags = [];
  if (item.tags) {
    data.tags = fromIterator(item.tags);
  }
  data.group = getId(data.group) || exports.UNSORTED.id;

  return data;
}

/*
 * Takes a data object and constructs a BookmarkItem instance
 * of it, recursively generating parent instances as well.
 *
 * Pass in a `cache` Map to reuse instances of
 * bookmark items to reduce overhead;
 * The cache object is a map of id to a deferred with a 
 * promise that resolves to the bookmark item.
 */
function construct (object, cache, forced) {
  let item = instantiate(object);
  let deferred = defer();

  // Item could not be instantiated
  if (!item)
    return resolve(null);

  // Return promise for item if found in the cache,
  // and not `forced`. `forced` indicates that this is the construct
  // call that should not read from cache, but should actually perform
  // the construction, as it was set before several async calls
  if (cache.has(item.id) && !forced)
    return cache.get(item.id).promise;
  else if (cache.has(item.id))
    deferred = cache.get(item.id);
  else
    cache.set(item.id, deferred);

  // When parent group is found in cache, use
  // the same deferred value
  if (item.group && cache.has(item.group)) {
    cache.get(item.group).promise.then(group => {
      item.group = group;
      deferred.resolve(item);
    });

  // If not in the cache, and a root group, return
  // the premade instance
  } else if (rootGroups.get(item.group)) {
    item.group = rootGroups.get(item.group);
    deferred.resolve(item);

  // If not in the cache or a root group, fetch the parent
  } else {
    cache.set(item.group, defer());
    fetchItem(item.group).then(group => {
      return construct(group, cache, true);
    }).then(group => {
      item.group = group;
      deferred.resolve(item);
    }, deferred.reject);
  }

  return deferred.promise;
}

function instantiate (object) {
  if (object.type === 'bookmark')
    return Bookmark(object);
  if (object.type === 'group')
    return Group(object);
  if (object.type === 'separator')
    return Separator(object);
  return null;
}

/**
 * Validates a bookmark item; will throw an error if ininvalid,
 * to be used with `promised`. As bookmark items check on their class,
 * this only checks ducktypes
 */
function validate (object) {
  if (!isDuckType(object)) return true;
  let contract = object.type === 'bookmark' ? bookmarkContract :
                 object.type === 'group' ? groupContract :
                 object.type === 'separator' ? separatorContract :
                 null;
  if (!contract) {
    throw Error('No type specified');
  }

  // If object has a property set, and undefined,
  // manually override with default as it'll fail otherwise
  let withDefaults = Object.keys(defaults).reduce((obj, prop) => {
    if (obj[prop] == null) obj[prop] = defaults[prop];
    return obj;
  }, extend(object));

  contract(withDefaults);
}

function isDuckType (item) {
  return !(item instanceof Bookmark) &&
    !(item instanceof Group) &&
    !(item instanceof Separator);
}

function saveId (unsaved, id) {
  itemMap.set(unsaved, id);
}

// Fetches an item's ID from itself, or from the mapped items
function getId (item) {
  return typeof item === 'number' ? item :
    item ? item.id || itemMap.get(item) :
    null;
}

/*
 * Set up the default, root groups
 */

let defaultGroupMap = {
  MENU: bmsrv.bookmarksMenuFolder,
  TOOLBAR: bmsrv.toolbarFolder,
  UNSORTED: bmsrv.unfiledBookmarksFolder
};

let rootGroups = new Map();

for (let i in defaultGroupMap) {
  let group = Object.freeze(Group({ title: i, id: defaultGroupMap[i] }));
  rootGroups.set(defaultGroupMap[i], group);
  exports[i] = group;
}

let defaults = {
  group: exports.UNSORTED,
  index: -1
};
