/* -*- indent-tabs-mode: nil; 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/. */

const { Cu } = require("chrome");
const { Class } = require("sdk/core/heritage");
const { emit } = require("sdk/event/core");
const { EventTarget } = require("sdk/event/target");
const { merge } = require("sdk/util/object");
const promise = require("promise");
const { InplaceEditor } = require("devtools/client/shared/inplace-editor");
const { on, forget } = require("devtools/client/projecteditor/lib/helpers/event");
const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {});

const HTML_NS = "http://www.w3.org/1999/xhtml";

/**
 * ResourceContainer is used as the view of a single Resource in
 * the tree.  It is not exported.
 */
var ResourceContainer = Class({
  /**
   * @param ProjectTreeView tree
   * @param Resource resource
   */
  initialize: function (tree, resource) {
    this.tree = tree;
    this.resource = resource;
    this.elt = null;
    this.expander = null;
    this.children = null;

    let doc = tree.doc;

    this.elt = doc.createElementNS(HTML_NS, "li");
    this.elt.classList.add("child");

    this.line = doc.createElementNS(HTML_NS, "div");
    this.line.classList.add("child");
    this.line.classList.add("entry");
    this.line.setAttribute("theme", "dark");
    this.line.setAttribute("tabindex", "0");

    this.elt.appendChild(this.line);

    this.highlighter = doc.createElementNS(HTML_NS, "span");
    this.highlighter.classList.add("highlighter");
    this.line.appendChild(this.highlighter);

    this.expander = doc.createElementNS(HTML_NS, "span");
    this.expander.className = "arrow expander";
    this.expander.setAttribute("open", "");
    this.line.appendChild(this.expander);

    this.label = doc.createElementNS(HTML_NS, "span");
    this.label.className = "file-label";
    this.line.appendChild(this.label);

    this.line.addEventListener("contextmenu", (ev) => {
      this.select();
      this.openContextMenu(ev);
    }, false);

    this.children = doc.createElementNS(HTML_NS, "ul");
    this.children.classList.add("children");

    this.elt.appendChild(this.children);

    this.line.addEventListener("click", (evt) => {
      this.select();
      this.toggleExpansion();
      evt.stopPropagation();
    }, false);
    this.expander.addEventListener("click", (evt) => {
      this.toggleExpansion();
      this.select();
      evt.stopPropagation();
    }, true);

    if (!this.resource.isRoot) {
      this.expanded = false;
    }
    this.update();
  },

  toggleExpansion: function () {
    if (!this.resource.isRoot) {
      this.expanded = !this.expanded;
    } else {
      this.expanded = true;
    }
  },

  destroy: function () {
    this.elt.remove();
    this.expander.remove();
    this.highlighter.remove();
    this.children.remove();
    this.label.remove();
    this.elt = this.expander = this.highlighter = this.children = this.label = null;
  },

  /**
   * Open the context menu when right clicking on the view.
   * XXX: We could pass this to plugins to allow themselves
   * to be register/remove items from the context menu if needed.
   *
   * @param Event e
   */
  openContextMenu: function (ev) {
    ev.preventDefault();
    let popup = this.tree.options.contextMenuPopup;
    popup.openPopupAtScreen(ev.screenX, ev.screenY, true);
  },

  /**
   * Update the view based on the current state of the Resource.
   */
  update: function () {
    let visible = this.tree.options.resourceVisible ?
      this.tree.options.resourceVisible(this.resource) :
      true;

    this.elt.hidden = !visible;

    this.tree.options.resourceFormatter(this.resource, this.label);

    this.expander.style.visibility = this.resource.hasChildren ? "visible" : "hidden";

  },

  /**
   * Select this view in the ProjectTreeView.
   */
  select: function () {
    this.tree.selectContainer(this);
  },

  /**
   * @returns Boolean
   *          Is this view currently selected
   */
  get selected() {
    return this.line.classList.contains("selected");
  },

  /**
   * Set the selected state in the UI.
   */
  set selected(v) {
    if (v) {
      this.line.classList.add("selected");
    } else {
      this.line.classList.remove("selected");
    }
  },

  /**
   * @returns Boolean
   *          Are any children visible.
   */
  get expanded() {
    return !this.elt.classList.contains("tree-collapsed");
  },

  /**
   * Set the visiblity state of children.
   */
  set expanded(v) {
    if (v) {
      this.elt.classList.remove("tree-collapsed");
      this.expander.setAttribute("open", "");
    } else {
      this.expander.removeAttribute("open");
      this.elt.classList.add("tree-collapsed");
    }
  }
});

/**
 * TreeView is a view managing a list of children.
 * It is not to be instantiated directly - only extended.
 * Use ProjectTreeView instead.
 */
var TreeView = Class({
  extends: EventTarget,

  /**
   * @param Document document
   * @param Object options
   *               - contextMenuPopup: a <menupopup> element
   *               - resourceFormatter: a function(Resource, DOMNode)
   *                 that renders the resource into the view
   *               - resourceVisible: a function(Resource) -> Boolean
   *                 that determines if the resource should show up.
   */
  initialize: function (doc, options) {
    this.doc = doc;
    this.options = merge({
      resourceFormatter: function (resource, elt) {
        elt.textContent = resource.toString();
      }
    }, options);
    this.models = new Set();
    this.roots = new Set();
    this._containers = new Map();
    this.elt = this.doc.createElementNS(HTML_NS, "div");
    this.elt.tree = this;
    this.elt.className = "sources-tree";
    this.elt.setAttribute("with-arrows", "true");
    this.elt.setAttribute("theme", "dark");
    this.elt.setAttribute("flex", "1");

    this.children = this.doc.createElementNS(HTML_NS, "ul");
    this.elt.appendChild(this.children);

    this.resourceChildrenChanged = this.resourceChildrenChanged.bind(this);
    this.removeResource = this.removeResource.bind(this);
    this.updateResource = this.updateResource.bind(this);
  },

  destroy: function () {
    this._destroyed = true;
    this.elt.remove();
  },

  /**
   * Helper function to create DOM elements for promptNew and promptEdit
   */
  createInputContainer: function () {
    let inputholder = this.doc.createElementNS(HTML_NS, "div");
    inputholder.className = "child entry";

    let expander = this.doc.createElementNS(HTML_NS, "span");
    expander.className = "arrow expander";
    expander.setAttribute("invisible", "");
    inputholder.appendChild(expander);

    let placeholder = this.doc.createElementNS(HTML_NS, "div");
    placeholder.className = "child";
    inputholder.appendChild(placeholder);

    return {inputholder, placeholder};
  },

  /**
   * Prompt the user to create a new file in the tree.
   *
   * @param string initial
   *               The suggested starting file name
   * @param Resource parent
   * @param Resource sibling
   *                 Which resource to put this next to.  If not set,
   *                 it will be put in front of all other children.
   *
   * @returns Promise
   *          Resolves once the prompt has been successful,
   *          Rejected if it is cancelled
   */
  promptNew: function (initial, parent, sibling = null) {
    let deferred = promise.defer();

    let parentContainer = this._containers.get(parent);
    let item = this.doc.createElement("li");
    item.className = "child";

    let {inputholder, placeholder} = this.createInputContainer();
    item.appendChild(inputholder);

    let children = parentContainer.children;
    sibling = sibling ? this._containers.get(sibling).elt : null;
    parentContainer.children.insertBefore(item, sibling ? sibling.nextSibling : children.firstChild);

    new InplaceEditor({
      element: placeholder,
      initial: initial,
      preserveTextStyles: true,
      start: editor => {
        editor.input.select();
      },
      done: function (val, commit) {
        if (commit) {
          deferred.resolve(val);
        } else {
          deferred.reject(val);
        }
        parentContainer.line.focus();
      },
      destroy: () => {
        item.parentNode.removeChild(item);
      },
    });

    return deferred.promise;
  },

  /**
   * Prompt the user to rename file in the tree.
   *
   * @param string initial
   *               The suggested starting file name
   * @param resource
   *
   * @returns Promise
   *          Resolves once the prompt has been successful,
   *          Rejected if it is cancelled
   */
  promptEdit: function (initial, resource) {
    let deferred = promise.defer();
    let item = this._containers.get(resource).elt;
    let originalText = item.childNodes[0];

    let {inputholder, placeholder} = this.createInputContainer();
    item.insertBefore(inputholder, originalText);

    item.removeChild(originalText);

    new InplaceEditor({
      element: placeholder,
      initial: initial,
      preserveTextStyles: true,
      start: editor => {
        editor.input.select();
      },
      done: function (val, commit) {
        if (val === initial) {
          item.insertBefore(originalText, inputholder);
        }

        item.removeChild(inputholder);

        if (commit) {
          deferred.resolve(val);
        } else {
          deferred.reject(val);
        }
      },
    });

    return deferred.promise;
  },

  /**
   * Add a new Store into the TreeView
   *
   * @param Store model
   */
  addModel: function (model) {
    if (this.models.has(model)) {
      // Requesting to add a model that already exists
      return;
    }
    this.models.add(model);
    let placeholder = this.doc.createElementNS(HTML_NS, "li");
    placeholder.style.display = "none";
    this.children.appendChild(placeholder);
    this.roots.add(model.root);
    model.root.refresh().then(root => {
      if (this._destroyed || !this.models.has(model)) {
        // model may have been removed during the initial refresh.
        // In this case, do not import the resource or add to DOM, just leave it be.
        return;
      }
      let container = this.importResource(root);
      container.line.classList.add("entry-group-title");
      container.line.setAttribute("theme", "dark");
      this.selectContainer(container);

      this.children.insertBefore(container.elt, placeholder);
      this.children.removeChild(placeholder);
    });
  },

  /**
   * Remove a Store from the TreeView
   *
   * @param Store model
   */
  removeModel: function (model) {
    this.models.delete(model);
    this.removeResource(model.root);
  },


  /**
   * Get the ResourceContainer.  Used for testing the view.
   *
   * @param Resource resource
   * @returns ResourceContainer
   */
  getViewContainer: function (resource) {
    return this._containers.get(resource);
  },

  /**
   * Select a ResourceContainer in the tree.
   *
   * @param ResourceContainer container
   */
  selectContainer: function (container) {
    if (this.selectedContainer === container) {
      return;
    }
    if (this.selectedContainer) {
      this.selectedContainer.selected = false;
    }
    this.selectedContainer = container;
    container.selected = true;
    emit(this, "selection", container.resource);
  },

  /**
   * Select a Resource in the tree.
   *
   * @param Resource resource
   */
  selectResource: function (resource) {
    this.selectContainer(this._containers.get(resource));
  },

  /**
   * Get the currently selected Resource
   *
   * @param Resource resource
   */
  getSelectedResource: function () {
    return this.selectedContainer.resource;
  },

  /**
   * Insert a Resource into the view.
   * Makes a new ResourceContainer if needed
   *
   * @param Resource resource
   */
  importResource: function (resource) {
    if (!resource) {
      return null;
    }

    if (this._containers.has(resource)) {
      return this._containers.get(resource);
    }
    var container = ResourceContainer(this, resource);
    this._containers.set(resource, container);
    this._updateChildren(container);

    on(this, resource, "children-changed", this.resourceChildrenChanged);
    on(this, resource, "label-change", this.updateResource);
    on(this, resource, "deleted", this.removeResource);

    return container;
  },

  /**
   * Remove a Resource (including children) from the view.
   *
   * @param Resource resource
   */
  removeResource: function (resource) {
    let toRemove = resource.allDescendants();
    toRemove.add(resource);
    for (let remove of toRemove) {
      this._removeResource(remove);
    }
  },

  /**
   * Remove an individual Resource (but not children) from the view.
   *
   * @param Resource resource
   */
  _removeResource: function (resource) {
    forget(this, resource);
    if (this._containers.get(resource)) {
      this._containers.get(resource).destroy();
      this._containers.delete(resource);
    }
    emit(this, "resource-removed", resource);
  },

  /**
   * Listener for when a resource has new children.
   * This can happen as files are being loaded in from FileSystem, for example.
   *
   * @param Resource resource
   */
  resourceChildrenChanged: function (resource) {
    this.updateResource(resource);
    this._updateChildren(this._containers.get(resource));
  },

  /**
   * Listener for when a label in the view has been updated.
   * For example, the 'dirty' plugin marks changed files with an '*'
   * next to the filename, and notifies with this event.
   *
   * @param Resource resource
   */
  updateResource: function (resource) {
    let container = this._containers.get(resource);
    container.update();
  },

  /**
   * Build necessary ResourceContainers for a Resource and its
   * children, then append them into the view.
   *
   * @param ResourceContainer container
   */
  _updateChildren: function (container) {
    let resource = container.resource;
    let fragment = this.doc.createDocumentFragment();
    if (resource.children) {
      for (let child of resource.childrenSorted) {
        let childContainer = this.importResource(child);
        fragment.appendChild(childContainer.elt);
      }
    }

    while (container.children.firstChild) {
      container.children.firstChild.remove();
    }

    container.children.appendChild(fragment);
  },
});

/**
 * ProjectTreeView is the implementation of TreeView
 * that is exported.  This is the class that is to be used
 * directly.
 */
var ProjectTreeView = Class({
  extends: TreeView,

  /**
   * See TreeView.initialize
   *
   * @param Document document
   * @param Object options
   */
  initialize: function (document, options) {
    TreeView.prototype.initialize.apply(this, arguments);
  },

  destroy: function () {
    this.forgetProject();
    TreeView.prototype.destroy.apply(this, arguments);
  },

  /**
   * Remove current project and empty the tree
   */
  forgetProject: function () {
    if (this.project) {
      forget(this, this.project);
      for (let store of this.project.allStores()) {
        this.removeModel(store);
      }
    }
  },

  /**
   * Show a project in the tree
   *
   * @param Project project
   *        The project to render into a tree
   */
  setProject: function (project) {
    this.forgetProject();
    this.project = project;
    if (this.project) {
      on(this, project, "store-added", this.addModel.bind(this));
      on(this, project, "store-removed", this.removeModel.bind(this));
      on(this, project, "project-saved", this.refresh.bind(this));
      this.refresh();
    }
  },

  /**
   * Refresh the tree with all of the current project stores
   */
  refresh: function () {
    for (let store of this.project.allStores()) {
      this.addModel(store);
    }
  }
});

exports.ProjectTreeView = ProjectTreeView;
