// Copyright 2012 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package collide.client.filetree;

import javax.annotation.Nullable;

import collide.client.filetree.FileTreeModelNetworkController.OutgoingController;
import collide.client.treeview.TreeNodeElement;

import com.google.collide.client.bootstrap.BootstrapSession;
import com.google.collide.client.util.PathUtil;
import com.google.collide.client.util.logging.Log;
import com.google.collide.dto.DirInfo;
import com.google.collide.dto.Mutation;
import com.google.collide.dto.ServerError.FailureReason;
import com.google.collide.dto.WorkspaceTreeUpdate;
import com.google.collide.dto.client.DtoClientImpls.DirInfoImpl;
import com.google.collide.dto.client.DtoClientImpls.WorkspaceTreeUpdateImpl;
import com.google.collide.json.client.JsoArray;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.StringUtils;
import com.google.common.base.Preconditions;

/**
 * Public API for interacting with the client side workspace file tree model.
 * Also exposes callbacks for mutations that have been applied to the model.
 * 
 * If you want to mutate the workspace file tree, which is a tree of
 * {@link FileTreeNode}'s you need to go through here.
 */
public class FileTreeModel {

  /**
   * Callback interface for requesting the root node, potentially
   * asynchronously.
   */
  public interface RootNodeRequestCallback {
    void onRootNodeAvailable(FileTreeNode root);
  }

  /**
   * Callback interface for requesting a node, potentially asynchronously.
   */
  public interface NodeRequestCallback {
    void onNodeAvailable(FileTreeNode node);

    /**
     * Called if the node does not exist.
     */
    void onNodeUnavailable();

    /**
     * Called if an error occurs while loading the node.
     */
    void onError(FailureReason reason);
  }

  /**
   * Callback interface for getting notified about changes to the workspace tree
   * model that have been applied by the FileTreeController.
   */
  public interface TreeModelChangeListener {

    /**
     * Notification that a node was added.
     */
    void onNodeAdded(PathUtil parentDirPath, FileTreeNode newNode);

    /**
     * Notification that a node was moved/renamed.
     *
     * @param oldPath the old node path
     * @param node the node that was moved, or null if the old path is not loaded. If both the old
     *        path and the new path are loaded, node == newNode and node's parent will be the target
     *        directory of the new path. If the new path is not loaded, node is the node that was in
     *        the old path.
     * @param newPath the new node path
     * @param newNode the new node, or null if the target directory is not loaded
     */
    void onNodeMoved(PathUtil oldPath, FileTreeNode node, PathUtil newPath, FileTreeNode newNode);

    /**
     * Notification that a set of nodes was removed.
     *
     * @param oldNodes a list of nodes that we removed. Every node will still have its parent filled
     */
    void onNodesRemoved(JsonArray<FileTreeNode> oldNodes);

    /**
     * Notification that a node was replaced (can be either a file or directory).
     *
     * @param oldNode the existing node that used to be in the file tree, or null if the workspace
     *        root is being set for the first time
     * @param newNode the node that replaces the {@code oldNode}. This will be the same
     *        {@link FileTreeNode#getNodeType()} as the node it is replacing.
     */
    void onNodeReplaced(@Nullable FileTreeNode oldNode, FileTreeNode newNode);
  }

  /**
   * A {@link TreeModelChangeListener} which does not perform any operations in
   * response to an event. Its only purpose is to allow clients to only override
   * the events matter to them.
   */
  public abstract static class AbstractTreeModelChangeListener implements TreeModelChangeListener {
    @Override
    public void onNodeAdded(PathUtil parentDirPath, FileTreeNode newNode) {
      // intentional no-op, clients should override if needed
    }

    @Override
    public void onNodeMoved(
        PathUtil oldPath, FileTreeNode node, PathUtil newPath, FileTreeNode newNode) {
      // intentional no-op, clients should override if needed
    }

    @Override
    public void onNodesRemoved(JsonArray<FileTreeNode> oldNodes) {
      // intentional no-op, clients should override if needed
    }

    @Override
    public void onNodeReplaced(FileTreeNode oldDir, FileTreeNode newDir) {
      // intentional no-op, clients should override if needed
    }
  }

  /**
   * A {@link TreeModelChangeListener} that performs the exact same action in
   * response to any and all tree mutations.
   */
  public abstract static class BasicTreeModelChangeListener implements TreeModelChangeListener {
    public abstract void onTreeModelChange();

    @Override
    public void onNodeAdded(PathUtil parentDirPath, FileTreeNode newNode) {
      onTreeModelChange();
    }

    @Override
    public void onNodeMoved(
        PathUtil oldPath, FileTreeNode node, PathUtil newPath, FileTreeNode newNode) {
      onTreeModelChange();
    }

    @Override
    public void onNodesRemoved(JsonArray<FileTreeNode> oldNodes) {
      onTreeModelChange();
    }

    @Override
    public void onNodeReplaced(FileTreeNode oldDir, FileTreeNode newDir) {
      onTreeModelChange();
    }
  }

  private interface ChangeDispatcher {
    void dispatch(TreeModelChangeListener changeListener);
  }

  private final JsoArray<TreeModelChangeListener> modelChangeListeners = JsoArray.create();
  private final OutgoingController outgoingNetworkController;
  
  private FileTreeNode workspaceRoot;
  private boolean disableChangeNotifications;

  /**
   * Tree revision that corresponds to the revision of the last
   * successfully applied tree mutation that this client is aware of.
   */
  private String lastAppliedTreeMutationRevision = "0";

  public FileTreeModel(
      FileTreeModelNetworkController.OutgoingController outgoingNetworkController) {
    this.outgoingNetworkController = outgoingNetworkController;
  }

  /**
   * Adds a node to our model by path.
   */
  public void addNode(PathUtil path, final FileTreeNode newNode, String workspaceRootId) {
    if (workspaceRoot == null) {
      // TODO: queue up this add?
      Log.warn(getClass(), "Attempting to add a node before the root is set", path);
      return;
    }

    // Find the parent directory of the node.
    final PathUtil parentDirPath = PathUtil.createExcludingLastN(path, 1);
    FileTreeNode parentDir = getWorkspaceRoot().findChildNode(parentDirPath);

    if (parentDir != null && parentDir.isComplete()) {
      // The parent directory is complete, so add the node.
      addNode(parentDir, newNode, workspaceRootId);
    } else {
      // The parent directory isn't complete, so do not add the node to the model, but update the
      // workspace root id.
      maybeSetLastAppliedTreeMutationRevision(workspaceRootId);
    }
  }

  /**
   * Adds a node to the model under the specified parent node.
   */
  public void addNode(FileTreeNode parentDir, FileTreeNode childNode, String workspaceRootId) {
    addNodeNoDispatch(parentDir, childNode);
    dispatchAddNode(parentDir, childNode, workspaceRootId);
  }

  private void addNodeNoDispatch(final FileTreeNode parentDir, final FileTreeNode childNode) {
    if (parentDir == null) {
      Log.error(getClass(), "Trying to add a child to a null parent!", childNode);
      return;
    }

    Log.debug(getClass(), "Adding ", childNode, " - to - ", parentDir);

    parentDir.addChild(childNode);
  }

  /**
   * Manually dispatch that a node was added.
   */
  void dispatchAddNode(
      final FileTreeNode parentDir, final FileTreeNode childNode, final String workspaceRootId) {
    dispatchModelChange(new ChangeDispatcher() {
      @Override
      public void dispatch(TreeModelChangeListener changeListener) {
        changeListener.onNodeAdded(parentDir.getNodePath(), childNode);
      }
    }, workspaceRootId);
  }

  /**
   * Moves/renames a node in the model.
   */
  public void moveNode(
      final PathUtil oldPath, final PathUtil newPath, final String workspaceRootId) {
    if (workspaceRoot == null) {
      // TODO: queue up this move?
      Log.warn(getClass(), "Attempting to move a node before the root is set", oldPath);
      return;
    }

    // Remove the node from its old path if the old directory is complete.
    final FileTreeNode oldNode = workspaceRoot.findChildNode(oldPath);
    if (oldNode == null) {
      /*
       * No node found at the old path - either it isn't loaded, or we optimistically updated
       * already. Verify that one of those is the case.
       */
      Preconditions.checkState(workspaceRoot.findClosestChildNode(oldPath) != null ||
          workspaceRoot.findChildNode(newPath) != null);
    } else {
      oldNode.setName(newPath.getBaseName());
      oldNode.getParent().removeChild(oldNode);
    }

    // Apply the new root id.
    maybeSetLastAppliedTreeMutationRevision(workspaceRootId);

    // Prepare a callback that will dispatch the onNodeMove event to listeners.    
    NodeRequestCallback callback = new NodeRequestCallback() {
      @Override
      public void onNodeAvailable(FileTreeNode newNode) {
        /*
         * If we had to request the target directory, replace the target node with the oldNode to
         * ensure that all properties (such as the rendered node and the fileEditSessionKey) are
         * copied over correctly.
         */
        if (oldNode != null && newNode != null && newNode != oldNode) {
          newNode.replaceWith(oldNode);
          newNode = oldNode;
        }

        // Dispatch a change event.
        final FileTreeNode finalNewNode = newNode;
        dispatchModelChange(new ChangeDispatcher() {
          @Override
          public void dispatch(TreeModelChangeListener changeListener) {
            changeListener.onNodeMoved(oldPath, oldNode, newPath, finalNewNode);
          }
        }, workspaceRootId);
      }

      @Override
      public void onNodeUnavailable() {
        // The node should be available because we are requesting the node using the root ID
        // immediately after the move.
        Log.error(getClass(),
            "Could not find moved node using the workspace root ID immediately after the move");        
      }

      @Override
      public void onError(FailureReason reason) {
        // Error already logged.
      }
    };

    // Request the target directory. 
    final PathUtil parentDirPath = PathUtil.createExcludingLastN(newPath, 1);
    FileTreeNode parentDir = workspaceRoot.findChildNode(parentDirPath);
    if (parentDir == null || !parentDir.isComplete()) {
      if (oldNode == null) {
        // Early exit if neither the old node nor the target directory is loaded.        
        return;
      } else {
        // If the parent directory was not loaded, don't bother loading it.
        callback.onNodeAvailable(null);
      }
    } else {
      if (oldNode == null) {
        // The old node doesn't exist, so we need to force a refresh of the target directory's
        // children by marking the target directory incomplete.
        DirInfoImpl parentDirView = parentDir.cast();
        parentDirView.setIsComplete(false);
      } else {
        // The old node exists and the target directory is loaded, so add the node to the target.
        parentDir.addChild(oldNode);
      }

      // Request the new node.
      requestWorkspaceNode(newPath, callback);
    }
  }

  /**
   * Removes a node from the model.
   * 
   * @param toDelete the {@link FileTreeNode} we want to remove.
   * @param workspaceRootId the new file tree revision
   * @return the node that was deleted from the model. This will return
   *         {@code null} if the input node is null or if the input node does
   *         not have a parent. Meaning if the input node is the root, this
   *         method will return {@code null}.
   */
  public FileTreeNode removeNode(final FileTreeNode toDelete, String workspaceRootId) {
    // If we found a node at the specified path, then remove it.
    if (deleteNodeNoDispatch(toDelete)) {
      final JsonArray<FileTreeNode> deletedNode = JsonCollections.createArray(toDelete);
      dispatchModelChange(new ChangeDispatcher() {
        @Override
        public void dispatch(TreeModelChangeListener changeListener) {
          changeListener.onNodesRemoved(deletedNode);
        }
      }, workspaceRootId);

      return toDelete;
    }

    return null;
  }

  /**
   * Removes a set of nodes from the model.
   * 
   * @param toDelete the {@link PathUtil}s for the nodes we want to remove.
   * @param workspaceRootId the new file tree revision
   * @return the nodes that were deleted from the model. This will return an
   *         empty list if we try to add a node before we have a root node set,
   *         or if the specified path does not exist..
   */
  public JsonArray<FileTreeNode> removeNodes(
      final JsonArray<PathUtil> toDelete, String workspaceRootId) {
    if (workspaceRoot == null) {
      // TODO: queue up this remove?
      Log.warn(getClass(), "Attempting to remove nodes before the root is set");
      return null;
    }

    final JsonArray<FileTreeNode> deletedNodes = JsonCollections.createArray();
    for (int i = 0; i < toDelete.size(); i++) {
      FileTreeNode node = workspaceRoot.findChildNode(toDelete.get(i));
      if (deleteNodeNoDispatch(node)) {
        deletedNodes.add(node);
      }
    }

    if (deletedNodes.size() == 0) {
      // if none of the nodes created a need to update the UI, just return an
      // empty list.
      return deletedNodes;
    }

    dispatchModelChange(new ChangeDispatcher() {
      @Override
      public void dispatch(TreeModelChangeListener changeListener) {
        changeListener.onNodesRemoved(deletedNodes);
      }
    }, workspaceRootId);

    return deletedNodes;
  }

  /**
   * Deletes a single node (does not update the UI).
   */
  private boolean deleteNodeNoDispatch(FileTreeNode node) {
    if (node == null || node.getParent() == null) {
      return false;
    }
    
    FileTreeNode parent = node.getParent();

    // Guard against someone installing a node of the same name in the parent
    // (meaning we are already gone.
    if (!node.equals(parent.getChildNode(node.getName()))) {

      // This means that the node we are removing from the tree is already
      // effectively removed from where it thinks it is.
      return false;
    }

    node.getParent().removeChild(node);
    return true;
  }

  /**
   * Replaces either the root node for this tree model, or replaces an existing directory node, or
   * replaces an existing file node.
   */
  public void replaceNode(PathUtil path, final FileTreeNode newNode, String workspaceRootId) {
    if (newNode == null) {
      return;
    }

    if (PathUtil.WORKSPACE_ROOT.equals(path)) {
      // Install the workspace root.
      final FileTreeNode oldDir = workspaceRoot;
      workspaceRoot = newNode;

      dispatchModelChange(new ChangeDispatcher() {
        @Override
        public void dispatch(TreeModelChangeListener changeListener) {
          changeListener.onNodeReplaced(oldDir, newNode);
        }
      }, workspaceRootId);
    } else {

      // Patch the model if there is one.
      if (workspaceRoot != null) {
        final FileTreeNode nodeToReplace = workspaceRoot.findChildNode(path);

        // Note. We do not support patching subtrees that don't already
        // exist. This subtree must have already existed, or have been
        // preceded by an ADD or COPY mutation.
        if (nodeToReplace == null) {
          return;
        }

        nodeToReplace.replaceWith(newNode);

        dispatchModelChange(new ChangeDispatcher() {
          @Override
          public void dispatch(TreeModelChangeListener changeListener) {
            changeListener.onNodeReplaced(nodeToReplace, newNode);
          }
        }, workspaceRootId);
      }
    }
  }

  /**
   * @return the current value of the workspaceRoot. Potentially {@code null} if
   *         the model has not yet been populated.
   */
  public FileTreeNode getWorkspaceRoot() {
    return workspaceRoot;
  }

  /**
   * Asks for the root node, potentially asynchronously if the model is not yet
   * populated. If the root node is already available then the callback will be
   * invoked synchronously.
   */
  public void requestWorkspaceRoot(final RootNodeRequestCallback callback) {
    FileTreeNode rootNode = getWorkspaceRoot();
    if (rootNode == null) {

      // Wait for the model to be populated.
      addModelChangeListener(new AbstractTreeModelChangeListener() {
        @Override
        public void onNodeReplaced(FileTreeNode oldNode, FileTreeNode newNode) {
          Preconditions.checkArgument(newNode.getNodePath().equals(PathUtil.WORKSPACE_ROOT),
              "Unexpected non-workspace root subtree replaced before workspace root was replaced: "
              + newNode.toString());
          
          // Should be resilient to concurrent modification!
          removeModelChangeListener(this);
          callback.onRootNodeAvailable(getWorkspaceRoot());
        }
      });

      return;
    }

    callback.onRootNodeAvailable(rootNode);
  }

  /**
   * Adds a {@link TreeModelChangeListener} to be notified of mutations applied
   * by the FileTreeController to the underlying workspace file tree model.
   * 
   * @param modelChangeListener the listener we are adding
   */
  public void addModelChangeListener(TreeModelChangeListener modelChangeListener) {
    modelChangeListeners.add(modelChangeListener);
  }

  /**
   * Removes a {@link TreeModelChangeListener} from the set of listeners
   * subscribed to model changes.
   */
  public void removeModelChangeListener(TreeModelChangeListener modelChangeListener) {
    modelChangeListeners.remove(modelChangeListener);
  }

  public void setDisableChangeNotifications(boolean disable) {
    this.disableChangeNotifications = disable;
  }

  private void dispatchModelChange(ChangeDispatcher dispatcher, String workspaceRootId) {

    // Update the tracked tip ID.
    maybeSetLastAppliedTreeMutationRevision(workspaceRootId);

    if (disableChangeNotifications) {
      return;
    }

    JsoArray<TreeModelChangeListener> copy = modelChangeListeners.slice(
        0, modelChangeListeners.size());
    for (int i = 0, n = copy.size(); i < n; i++) {
      dispatcher.dispatch(copy.get(i));
    }
  }

  /**
   * @return the file tree revision associated with the last seen Tree mutation.
   */
  public String getLastAppliedTreeMutationRevision() {
    return lastAppliedTreeMutationRevision;
  }

  /**
   * Bumps the tracked Root ID for the last applied tree mutation, if the
   * version happens to be larger than the version we are tracking.
   */
  public void maybeSetLastAppliedTreeMutationRevision(String lastAppliedTreeMutationRevision) {
    // TODO: Ensure numeric comparison survives ID obfuscation.
    try {
      long newRevision = StringUtils.toLong(lastAppliedTreeMutationRevision);
      long lastRevision = StringUtils.toLong(this.lastAppliedTreeMutationRevision);
      this.lastAppliedTreeMutationRevision = (newRevision > lastRevision)
          ? lastAppliedTreeMutationRevision : this.lastAppliedTreeMutationRevision;
      // TODO: this should be monotonically increasing; if it's not, we missed an update.
    } catch (NumberFormatException e) {
      Log.error(getClass(), "Root ID is not a numeric long!", lastAppliedTreeMutationRevision);
    }
  }

  /**
   * Folks that want to mutate the file tree should obtain a skeletal {@link WorkspaceTreeUpdate}
   * using this factory method.
   */
  public WorkspaceTreeUpdateImpl makeEmptyTreeUpdate() {
    if (this.lastAppliedTreeMutationRevision == null) {
      throw new IllegalStateException(
          "Attempted to mutate the tree before the workspace file tree was loaded at least once!");
    }

    return WorkspaceTreeUpdateImpl.make()
        .setAuthorClientId(BootstrapSession.getBootstrapSession().getActiveClientId())
        .setMutations(JsoArray.<Mutation>create());
  }

  /**
   * Calculates the list of expanded paths. The list only contains the paths of the deepest expanded
   * directories. Parent directories are assumed to be open as well.
   *
   * @return the list of expanded paths, or null if the workspace root is not loaded
   */
  public JsoArray<String> calculateExpandedPaths() {
    // Early exit if the root isn't loaded yet.
    if (workspaceRoot == null) {
      return null;
    }

    // Walk the tree looking for expanded paths.
    JsoArray<String> expandedPaths = JsoArray.create();
    calculateExpandedPathsRecursive(workspaceRoot, expandedPaths);
    return expandedPaths;
  }

  /**
   * Calculates the list of expanded paths beneath the specified node and adds them to expanded
   * path.  If none of the children 
   *
   * @param node the directory containing the expanded paths
   * @param expandedPaths the running list of expanded paths
   */
  private void calculateExpandedPathsRecursive(FileTreeNode node, JsoArray<String> expandedPaths) {
    assert node.isDirectory() : "node must be a directory";

    // Check if the directory is expanded. The root is always expanded.
    if (node != workspaceRoot) {
      TreeNodeElement<FileTreeNode> dirElem = node.getRenderedTreeNode();
      if (!dirElem.isOpen()) {
        return;
      }
    }

    // Recursively search for expanded subdirectories.
    int expandedPathsCount = expandedPaths.size();
    DirInfoImpl dir = node.cast();
    JsonArray<DirInfo> subDirs = dir.getSubDirectories();
    if (subDirs != null) {
      for (int i = 0; i < subDirs.size(); i++) {
        DirInfo subDir = subDirs.get(i);
        calculateExpandedPathsRecursive((FileTreeNode) subDir, expandedPaths);
      }
    }

    // Add this directory if none of its descendants were added.
    if (expandedPathsCount == expandedPaths.size()) {
      expandedPaths.add(node.getNodePath().getPathString());
    }
  }
  
  /**
   * Asks for the node at the specified path, potentially asynchronously if the model does not yet
   * contain the node. If the node is already available then the callback will be invoked
   * synchronously.
   *
   * @param path the path to the node, which must be a file (not a directory)
   * @param callback the callback to invoke when the node is ready
   */
  public void requestWorkspaceNode(final PathUtil path, final NodeRequestCallback callback) {
    outgoingNetworkController.requestWorkspaceNode(this, path, callback);
  }
  
  /**
   * Asks for the children of the specified node.
   *
   * @param node a directory node
   * @param callback an optional callback that will be notified once the children are fetched. If
   *        null, this method will alert the user if there was an error
   */
  public void requestDirectoryChildren(FileTreeNode node,
      @Nullable final NodeRequestCallback callback) {
    outgoingNetworkController.requestDirectoryChildren(this, node, callback);
  }

}
