// 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 com.google.collide.server.documents;

import com.google.collide.dto.DocOp;
import com.google.collide.dto.DocumentSelection;
import com.google.collide.dto.FileContents;
import com.google.collide.dto.FileContents.ContentType;
import com.google.collide.dto.server.DtoServerImpls.*;
import com.google.collide.json.server.JsonArrayListAdapter;
import com.google.collide.server.documents.VersionedDocument.AppliedDocOp;
import com.google.collide.server.documents.VersionedDocument.DocumentOperationException;
import com.google.collide.server.participants.Participants;
import com.google.collide.server.shared.BusModBase;
import com.google.collide.server.shared.util.Dto;
import com.google.collide.shared.MimeTypes;
import com.google.common.base.Charsets;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.vertx.core.Handler;
import io.vertx.core.eventbus.Message;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.codec.binary.StringUtils;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.SortedMap;

/**
 * Backend service that maintains in-memory edit sessions for documents that are being
 * collaboratively edited.
 *
 * All text mutations and document related changes are handled by this service.
 *
 */
public class EditSessions extends BusModBase {
  private static final Gson gson = new GsonBuilder().registerTypeAdapter(
      DocOpComponentImpl.class, new DocOpComponentDeserializer()).serializeNulls().create();

  /**
   * Receives Document operations and applies them to the corresponding FileEditSession.
   *
   *  If there is no associated FileEditSession, we need log an error since that probably means we
   * have a stale client.
   */
  class DocumentMutator implements Handler<Message<JsonObject>> {
    private final SelectionTracker selectionTracker = new SelectionTracker();

    @Override
    public void handle(Message<JsonObject> message) {
      ClientToServerDocOpImpl wrappedDocOp =
          ClientToServerDocOpImpl.fromJsonString(Dto.get(message));

      String resourceId = wrappedDocOp.getFileEditSessionKey();

      FileEditSession editSession = editSessions.get(resourceId);

      // Apply the DocOp.
      if (editSession != null) {
        List<String> docOps = ((JsonArrayListAdapter<String>) wrappedDocOp.getDocOps2()).asList();
        ServerToClientDocOpsImpl appliedDocOps = applyMutation(
            docOps, wrappedDocOp.getClientId(), wrappedDocOp.getCcRevision(),
            wrappedDocOp.getSelection(), resourceId, editSession);
        message.reply(Dto.wrap(appliedDocOps));
      }
    }

    private List<DocOp> deserializeDocOps(List<String> serializedDocOps) {
      List<DocOp> docOps = new ArrayList<DocOp>();
      for (String serializedDocOp : serializedDocOps) {
        docOps.add(gson.fromJson(serializedDocOp, DocOpImpl.class));
      }
      return docOps;
    }

    private ServerToClientDocOpsImpl applyMutation(List<String> serializedDocOps, String authorId,
        int ccRevision, DocumentSelection selection, String resourceId, FileEditSession editSession) {
      try {
        List<DocOp> docOps = deserializeDocOps(serializedDocOps);
        VersionedDocument.ConsumeResult result =
            editSession.consume(docOps, authorId, ccRevision, selection);

        // See if we need to update the selection
        checkForSelectionChange(
            authorId, resourceId, editSession.getDocument(), result.transformedDocumentSelection);

        // Construct the Applied DocOp that we want to broadcast.
        SortedMap<Integer, AppliedDocOp> appliedDocOps = result.appliedDocOps;
        List<ServerToClientDocOpImpl> appliedDocOpsList = Lists.newArrayList();
        for (Entry<Integer, VersionedDocument.AppliedDocOp> entry : appliedDocOps.entrySet()) {
          DocOpImpl docOp = (DocOpImpl) entry.getValue().docOp;
          ServerToClientDocOpImpl wrappedBroadcastDocOp = ServerToClientDocOpImpl.make()
              .setClientId(authorId).setAppliedCcRevision(entry.getKey()).setDocOp2(docOp)
              .setFileEditSessionKey(resourceId)
              .setFilePath(editSession.getSavedPath());
          appliedDocOpsList.add(wrappedBroadcastDocOp);
        }

        // Add the selection to the last DocOp if there was one.
        if (result.transformedDocumentSelection != null && appliedDocOpsList.size() > 0) {
          appliedDocOpsList.get(appliedDocOpsList.size() - 1)
              .setSelection((DocumentSelectionImpl) result.transformedDocumentSelection);
        }

        // Broadcast the applied DocOp all the participants, ignoring the sender.
        ServerToClientDocOpsImpl broadcastedDocOps =
            ServerToClientDocOpsImpl.make().setDocOps(appliedDocOpsList);
        vertx.eventBus().send("participants.broadcast", new JsonObject().put(
            Participants.OMIT_SENDER_TAG, authorId).put(
            "payload", broadcastedDocOps.toJson()));
        return broadcastedDocOps;
      } catch (DocumentOperationException e) {
        logger.error(String.format("Failed to apply DocOps [%s]", serializedDocOps));
      }
      return null;
    }

    private void checkForSelectionChange(String clientId, String resourceId,
        VersionedDocument document, DocumentSelection documentSelection) {
      /*
       * Currently, doc ops either contain text changes or selection changes (via annotation doc op
       * components). Both of these modify the user's selection/cursor.
       */
      selectionTracker.selectionChanged(clientId, resourceId, document, documentSelection);
    }
  }

  /**
   * Replies with the DocOps that were missed by the requesting client.
   */
  class DocOpRecoverer implements Handler<Message<JsonObject>> {
    @Override
    public void handle(Message<JsonObject> event) {
      RecoverFromMissedDocOpsImpl req = RecoverFromMissedDocOpsImpl.fromJsonString(Dto.get(event));

      String resourceId = req.getFileEditSessionKey();
      FileEditSession editSession = editSessions.get(resourceId);

      if (editSession == null) {
        logger.error("No edit session for resourceId " + resourceId);

        // TODO: This is going to leave the reply handler hanging.
        return;
      }

      List<String> docOps = ((JsonArrayListAdapter<String>) req.getDocOps2()).asList();

      // If the client is re-sending any unacked doc ops, apply them first
      if (req.getDocOps2().size() > 0) {
        documentMutator.applyMutation(
            docOps, req.getClientId(), req.getCurrentCcRevision(), null, resourceId, editSession);
      }

      // Get all the applied doc ops the client doesn't know about
      SortedMap<Integer, VersionedDocument.AppliedDocOp> appliedDocOps =
          editSession.getDocument().getAppliedDocOps(req.getCurrentCcRevision() + 1);

      List<ServerToClientDocOpImpl> appliedDocOpsList = Lists.newArrayList();
      for (Entry<Integer, VersionedDocument.AppliedDocOp> entry : appliedDocOps.entrySet()) {
        DocOpImpl docOp = (DocOpImpl) entry.getValue().docOp;
        ServerToClientDocOpImpl wrappedBroadcastDocOp = ServerToClientDocOpImpl.make()
            .setClientId(req.getClientId()).setAppliedCcRevision(entry.getKey()).setDocOp2(docOp)
            .setFileEditSessionKey(resourceId)
            .setFilePath(editSession.getSavedPath());
        appliedDocOpsList.add(wrappedBroadcastDocOp);
      }

      RecoverFromMissedDocOpsResponseImpl resp =
          RecoverFromMissedDocOpsResponseImpl.make().setDocOps(appliedDocOpsList);
      event.reply(Dto.wrap(resp));
    }
  }

  /**
   * Creates a FileEditSession if there is not one already present and
   */
  class EditSessionCreator implements Handler<Message<JsonObject>> {

    /**
     * Whether or not to provision an edit session if one is missing, before sending the file
     * contents
     */
    private final boolean provisionEditSession;

    EditSessionCreator(boolean provisionEditSession) {
      this.provisionEditSession = provisionEditSession;
    }

    @Override
    public void handle(final Message<JsonObject> message) {
      final GetFileContentsImpl request = GetFileContentsImpl.fromJsonString(Dto.get(message));

      // Resolve the resource IDs from the requested path.
      vertx.eventBus().<JsonObject>send("tree.getResourceIds",
          new JsonObject().put("paths", new JsonArray().add(request.getPath())),
          async -> {
            /**
             * Sends the contents of a file to the requester. The files will be served out of the
             * FileEditSession if the contents are being edited, otherwise they will simply be
             * served from disk.
             */
              Message<JsonObject> event = async.result();
              JsonArray resourceIdArr = event.body().getJsonArray("resourceIds");
              List<String> resourceIds = resourceIdArr.getList();
              String resourceId = resourceIds.get(0);

              String currentPath = stripLeadingSlash(request.getPath());
              FileEditSession editSession = editSessions.get(resourceId);

              // Create the DTO for the file contents response. We will build it up later in the
              // method.
              String mimeType = MimeTypes.guessMimeType(currentPath, false);
              FileContentsImpl fileContentsDto =
                  FileContentsImpl.make().setMimeType(mimeType).setPath(currentPath);

              if (editSession == null) {
                // We need to start a new edit session.
                String text = "";
                File file = new File(currentPath);
                try {
                  text = Files.toString(file, Charsets.UTF_8);
                } catch (IOException e) {
                  logger.error(
                      String.format("Failed to read text contents for path [%s]", currentPath));

                  // Send back a no file indicating that file does not exist.
                  sendContent(message, currentPath, null, false);
                  return;
                }

                if (provisionEditSession) {

                  // Provision a new edit session and fall through.
                  editSession =
                      new FileEditSessionImpl(resourceId, currentPath, text, null, logger);
                  editSessions.put(resourceId, editSession);

                  // Update the last opened file.
                  vertx.eventBus().send("workspace.setLastOpenedFile",
                      new JsonObject().put("resourceId", resourceId));
                } else {

                  // Just send the contents as they were read from disk and return.
                  String dataBase64 = MimeTypes.looksLikeImage(mimeType) ? StringUtils
                      .newStringUtf8(Base64.encodeBase64(text.getBytes())) : null;
                  fileContentsDto.setContents(dataBase64).setContentType(
                      dataBase64 == null ? ContentType.UNKNOWN_BINARY : ContentType.IMAGE);
                  sendContent(message, currentPath, fileContentsDto, true);
                  return;
                }
              }

              // Populate file contents response Dto with information from the edit session.
              fileContentsDto.setFileEditSessionKey(resourceId)
                  .setCcRevision(editSession.getDocument().getCcRevision())
                  .setContents(editSession.getContents()).setContentType(ContentType.TEXT);

              // Extract the contents from the edit session before sending.
              sendContent(message, currentPath, fileContentsDto, true);
           });
    }
  }

  void sendContent(
      Message<JsonObject> event, String path, FileContents fileContents, boolean fileExists) {
    GetFileContentsResponseImpl response = GetFileContentsResponseImpl.make()
        .setFileExists(fileExists).setFileContents((FileContentsImpl) fileContents);
    event.reply(Dto.wrap(response.toJson()));
  }

  /**
   * Iterates through all open, dirty edit sessions and saves them to disk.
   */
  class FileSaver implements Handler<Message<JsonObject>> {
    @Override
    public void handle(Message<JsonObject> message) {
      saveAll();
    }

    void saveAll() {
      Set<Entry<String, FileEditSession>> entries = editSessions.entrySet();
      Iterator<Entry<String, FileEditSession>> entryIter = entries.iterator();
      final JsonArray resourceIds = new JsonArray();
      while (entryIter.hasNext()) {
        Entry<String, FileEditSession> entry = entryIter.next();
        String resourceId = entry.getKey();
        FileEditSession editSession = entry.getValue();
        if (editSession.hasChanges()) {
          resourceIds.add(resourceId);
        }
      }

      // Resolve the current paths of opened files in case they have been moved.
      eb.<JsonObject>send("tree.getCurrentPaths", new JsonObject().put("resourceIds", resourceIds),
          message -> {
          final Message<JsonObject> event = message.result();

          if (message.failed()) {
            logger.error("Message failed calling tree.getCurrentPaths for resourceIds " + resourceIds, message.cause());
            return;
          }
          JsonArray currentPaths = event.body().getJsonArray("paths");
          Iterator<Object> pathIter = currentPaths.iterator();
          Iterator<Object> resourceIter = resourceIds.iterator();

          if (currentPaths.size() != resourceIds.size()) {
            logger.error(String.format(
                "Received [%d] paths in response to a request specifying [%d] resourceIds",
                currentPaths.size(), resourceIds.size()));
          }

          // Iterate through all the resolved paths and save the files to disk.
          while (pathIter.hasNext()) {
            String path = (String) pathIter.next();
            String resourceId = (String) resourceIter.next();

            if (path != null) {
              FileEditSession editSession = editSessions.get(resourceId);
              if (editSession != null) {
                try {
                  editSession.save(stripLeadingSlash(path));
                } catch (IOException e) {
                  logger.error(String.format("Failed to save file [%s]", path), e);
                }
              }
            }
          }
        }
      );
    }
  }

  /**
   * Removes an edit session, and notifies clients that they should reload their opened document.
   */
  class EditSessionRemover implements Handler<Message<JsonObject>> {
    @Override
    public void handle(Message<JsonObject> message) {
      String resourceId = message.body().getString("resourceId");
      if (resourceId != null) {
        editSessions.remove(resourceId);
      }
      // TODO: Notify clients to reload their opened document.
    }
  }

  private final Map<String, FileEditSession> editSessions = new HashMap<String, FileEditSession>();
  private final FileSaver fileSaver = new FileSaver();
  private final DocumentMutator documentMutator = new DocumentMutator();
  private String addressBase;

  @Override
  public void start() {
    super.start();
    this.addressBase = getOptionalStringConfig("address", "documents");
    vertx.eventBus().consumer(addressBase + ".mutate", documentMutator);
    vertx.eventBus().consumer(
        addressBase + ".createEditSession", new EditSessionCreator(true));
    vertx.eventBus().consumer(
        addressBase + ".getFileContents", new EditSessionCreator(false));
    vertx.eventBus().consumer(addressBase + ".saveAll", fileSaver);
    vertx.eventBus().consumer(addressBase + ".removeEditSession", new EditSessionRemover());
    vertx.eventBus().consumer(addressBase + ".recoverMissedDocop", new DocOpRecoverer());

    // TODO: Handle content changes on disk and synthesize a docop to apply to the in-memory edit
    // session, and broadcast to all clients.

    // Set up a regular save interval to flush to disk.
    vertx.setPeriodic(1500, new Handler<Long>() {
        @Override
      public void handle(Long event) {
        fileSaver.saveAll();
      }
    });
  }

  /**
   * This verticle needs to take "workspace rooted paths", which begin with a leading '/', and make
   * them relative to the base directory for the associated classloader for this verticle. That is,
   * assume that all paths are relative to what our local view of '.' is on the file system. We
   * simply strip the leading slash.
   */
  private static String stripLeadingSlash(String relative) {
    if (relative == null) {
      return null;
    } else {
      return relative.charAt(0) == '/' ? relative.substring(1) : relative;
    }
  }
}
