// 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.server.documents.VersionedDocument.DocumentOperationException;
import com.google.collide.server.documents.VersionedDocument.VersionedText;
import com.google.collide.server.shared.merge.ConflictChunk;
import com.google.collide.server.shared.merge.MergeChunk;
import com.google.collide.server.shared.merge.MergeResult;
import com.google.collide.server.shared.util.FileHasher;
import com.google.collide.shared.document.anchor.Anchor;
import com.google.collide.shared.document.anchor.Anchor.ShiftListener;
import com.google.collide.shared.document.anchor.AnchorManager;
import com.google.collide.shared.document.anchor.AnchorType;
import com.google.collide.shared.document.anchor.InsertionPlacementStrategy;
import com.google.collide.shared.ot.DocOpUtils;
import com.google.common.base.Charsets;
import com.google.common.collect.Lists;
import com.google.common.io.Files;
import com.google.protobuf.ByteString;
import io.vertx.core.logging.Logger;

import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.List;

/**
 * Default implementation of {@link FileEditSession}.
 *
 * <p>
 * This class is thread-safe.
 *
 */
final class FileEditSessionImpl implements FileEditSession {

  /**
   * Bundles together a snapshot of the text of this file with any conflict chunks.
   */
  private static class VersionedTextAndConflictChunksImpl
      implements
        VersionedTextAndConflictChunks {
    private final VersionedText text;
    private final List<AnchoredConflictChunk> conflictChunks;

    VersionedTextAndConflictChunksImpl(
        VersionedText text, List<AnchoredConflictChunk> conflictChunks) {
      this.text = text;
      this.conflictChunks = conflictChunks;
    }

    @Override
    public VersionedText getVersionedText() {
      return text;
    }

    @Override
    public List<AnchoredConflictChunk> getConflictChunks() {
      return conflictChunks;
    }
  }

  private static class AnchoredConflictChunk extends ConflictChunk {

    private static final AnchorType CONFLICT_CHUNK_START_LINE =
        AnchorType.create(FileEditSessionImpl.class, "conflictChunkStart");
    private static final AnchorType CONFLICT_CHUNK_END_LINE =
        AnchorType.create(FileEditSessionImpl.class, "conflictChunkEnd");

    public final Anchor startLineAnchor;
    public final Anchor endLineAnchor;

    public AnchoredConflictChunk(ConflictChunk chunk, VersionedDocument doc) {
      super(chunk, chunk.isResolved());

      // Add anchors at the conflict regions' boundaries, so their position/size
      // gets adjusted automatically as the user enters text in and around them.
      startLineAnchor = doc.addAnchor(
          CONFLICT_CHUNK_START_LINE, chunk.getStartLine(), AnchorManager.IGNORE_COLUMN);
      startLineAnchor.setRemovalStrategy(Anchor.RemovalStrategy.SHIFT);
      startLineAnchor.getShiftListenerRegistrar().add(new ShiftListener() {
          @Override
        public void onAnchorShifted(Anchor anchor) {
          setStartLine(anchor.getLineNumber());
        }
      });
      endLineAnchor =
          doc.addAnchor(CONFLICT_CHUNK_END_LINE, chunk.getEndLine(), AnchorManager.IGNORE_COLUMN);
      endLineAnchor.setInsertionPlacementStrategy(InsertionPlacementStrategy.LATER);
      endLineAnchor.setRemovalStrategy(Anchor.RemovalStrategy.SHIFT);
      endLineAnchor.getShiftListenerRegistrar().add(new ShiftListener() {
          @Override
        public void onAnchorShifted(Anchor anchor) {
          setEndLine(anchor.getLineNumber());
        }
      });
    }
  }

  /**
   * Given a merge result from the originally conflicted state, construct conflict chunks for it.
   */
  private static List<ConflictChunk> constructConflictChunks(MergeResult mergeResult) {
    List<ConflictChunk> conflicts = Lists.newArrayList();
    for (MergeChunk mergeChunk : mergeResult.getMergeChunks()) {
      if (mergeChunk.hasConflict()) {
        conflicts.add(new ConflictChunk(mergeChunk));
      }
    }
    return conflicts;
  }

  /**
   * Document that contains the file contents.
   */
  private VersionedDocument contents;

  /** The list of conflict chunks for this file. */
  private final List<AnchoredConflictChunk> conflictChunks = Lists.newArrayList();

  /*
   * The size and sha1 fields don't actually need to stay in lock-step with the doc contents since
   * there's no public API for retrieving a snapshot of both values. Thus, we don't need blocking
   * synchronization. We do however need to ensure that updates made by one thread are seen by other
   * threads, so they must be declared volatile.
   */

  /** Size of the file, in bytes. Lazily computed by {@link #getSize()}. */
  private Integer size = null;

  /** SHA-1 hash of the file contents. Lazily computed by {@link #getSha1()}. */
  private ByteString sha1 = null;

  /** CC revision of the document that we last saved */
  private int lastSavedCcRevision;

  /** CC revision of the document after the last mutation was applied */
  private int lastMutationCcRevision;

  /** True if the file-edit session has been closed */
  private boolean closed = false;

  /** When this file edit session was closed. Makes sense only if closed = true. */
  private long closedTimeMs;

  /** Time that this FileEditSession was created (millis since epoch) */
  private final long createdAt = System.currentTimeMillis();

  private OnCloseListener onCloseListener;

  /** The ID of the resource this edit session is opened for. */
  private final String resourceId;

  private final Logger logger;

  protected String lastSavedPath;

  /**
   * Constructs a {@link FileEditSessionImpl} for a file.
   *
   * @param resourceId the identifier for the resource we are editing.
   * @param initialContents the initial contents of the file
   * @param mergeResult if non-null the merge info related to the out of date
   */
  FileEditSessionImpl(String resourceId, String path, String initialContents,
      @Nullable MergeResult mergeResult, Logger logger) {
    this.resourceId = resourceId;
    this.lastSavedPath = path;
    this.logger = logger;
    this.contents = new VersionedDocument(initialContents, logger);

    if (mergeResult != null) {
      // Construct conflict chunks.
      List<ConflictChunk> chunks = constructConflictChunks(mergeResult);
      this.contents = new VersionedDocument(mergeResult.getMergedText(), logger);
      if (chunks.size() == 0) {
        logger.error(String.format("Non-null MergeResult passed to FileEditSession for file that"
            + " should have merged cleanly: [%s]", this));
      }

      for (ConflictChunk chunk : chunks) {
        this.conflictChunks.add(new AnchoredConflictChunk(chunk, contents));
      }
    }

    this.lastSavedCcRevision = contents.getCcRevision();
    this.lastMutationCcRevision = 0;

    logger.debug(String.format("FileEditSession [%s] was created at [%d]", this, createdAt));
  }

  @Override
  protected void finalize() throws Throwable {
    try {
      if (!closed) {
        logger.warn(
            String.format("FileEditSession [%s] finalized without being closed first", this));
        close();
      }
    } catch (Throwable thrown) {
      logger.error(
          String.format("Uncaught Throwable in FileEditSessionImpl.finalize of [%s]", this),
          thrown);
    } finally {
      super.finalize();
    }
  }

  private void checkNotClosed() {
    if (closed) {
      throw new FileEditSessionClosedException(resourceId, closedTimeMs);
    }
  }

  @Override
  public void close() {
    // if already closed, do nothing and silently return
    if (!closed) {
      closed = true;
      return;
    }
    closedTimeMs = System.currentTimeMillis();

    // TODO: Maybe change the semantics of this method to block until
    // all outstanding calls to other methods guarded by checkNotClosed()
    // finish. IncrementableCountDownLatch would do the trick.

    if (hasChanges()) {
      logger.warn(String.format("FileEditSession [%s] closed while dirty", this));
    }

    if (onCloseListener != null) {
      onCloseListener.onClosed();
    }
  }

  @Override
  public synchronized void setOnCloseListener(OnCloseListener listener) {
    if (this.onCloseListener != null) {
      throw new IllegalStateException("One listener already registered.");
    }
    this.onCloseListener = listener;
  }

  @Override
  public VersionedDocument.ConsumeResult consume(List<DocOp> docOps, String authorClientId,
      int intendedCcRevision, DocumentSelection selection) throws DocumentOperationException {

    checkNotClosed();

    boolean containsMutation = DocOpUtils.containsMutation(docOps);

    VersionedDocument.ConsumeResult result =
        contents.consume(docOps, authorClientId, intendedCcRevision, selection);

    if (containsMutation) {
      lastMutationCcRevision = contents.getCcRevision();

      // Reset the cached size and SHA-1. We'll wait until someone actually calls getSize() or
      // getSha1() to recompute them.
      size = null;
      sha1 = null;
    }
    return result;
  }

  private String getText() {
    return contents.asText().text;
  }

  @Override
  public String getContents() {
    checkNotClosed();
    return getText();
  }

  public int getCcRevision() {
    return lastMutationCcRevision;
  }

  @Override
  public int getSize() {
    checkNotClosed();

    if (size == null) {
      try {
        size = getText().getBytes("UTF-8").length;
      } catch (UnsupportedEncodingException e) {
        // UTF-8 is a charset required by Java spec, per javadoc of
        // java.nio.charset.Charset, so this can't happen.
        throw new RuntimeException("UTF-8 not supported in this JVM?!", e);
      }
    }
    return size;
  }

  @Override
  public ByteString getSha1() {
    checkNotClosed();

    if (sha1 == null) {
      sha1 = FileHasher.getSha1(getText());
    }
    return sha1;
  }

  @Override
  public VersionedDocument getDocument() {
    checkNotClosed();
    return contents;
  }

  @Override
  public String getFileEditSessionKey() {
    // probably ok to call on a closed FileEditSession
    return resourceId;
  }

  @Override
  public boolean hasChanges() {
    return lastSavedCcRevision < lastMutationCcRevision;
  }


  @Override
  public void save(String currentPath) throws IOException {
    checkNotClosed();

    if (currentPath == null) {
      logger.fatal(String.format("We do do not know the path for edit session [%s]!", this));
      return;
    }

    // Get a consistent snapshot of the raw text and conflict chunks
    VersionedTextAndConflictChunksImpl snapshot = getContentsAndConflictChunks();
    String text = snapshot.getVersionedText().text;
    List<AnchoredConflictChunk> conflictChunks = snapshot.getConflictChunks();

    if (hasUnresolvedConflictChunks(conflictChunks)) {
      // TODO: There are conflict chunks in this file that need resolving.
      saveConflictChunks(currentPath, text, conflictChunks);
    } else {

      // Remove all conflict chunk anchors from the document
      for (AnchoredConflictChunk conflict : conflictChunks) {
        contents.removeAnchor(conflict.startLineAnchor);
        contents.removeAnchor(conflict.endLineAnchor);
      }
      conflictChunks.clear();
    }

    saveChanges(currentPath, text);

    lastSavedCcRevision = snapshot.getVersionedText().ccRevision;
    lastSavedPath = currentPath;
    logger.debug(String.format("Saved file [%s]", this));
  }

  private void saveChanges(String path, String text) throws IOException {
    /*
     * TODO: what we really should do is track lastModified. Then we can lock,
     * check the lastModified, and merge in any local FS changes that happened
     * since we last saved.
     *
     * We should also listen on "documents.fileSystemEvents" for to get
     * notified instantly when the FS version changes, so we can eagerly apply
     * the delta and push to clients.
     */
    logger.debug(String.format("Saving file [%s]", path));

    File file = new File(path);
    Files.write(text, file, Charsets.UTF_8);
  }

  private void saveConflictChunks(
      String path, String text, List<AnchoredConflictChunk> conflictChunks) {
    // TODO: Write the conflict chunks to some out of band location.
  }

  @Override
  public List<ConflictChunk> getConflictChunks() {
    return Lists.newArrayList((Iterable<? extends ConflictChunk>) conflictChunks);
  }

  @Override
  public VersionedTextAndConflictChunksImpl getContentsAndConflictChunks() {
    checkNotClosed();

    return new VersionedTextAndConflictChunksImpl(contents.asText(), Lists.newArrayList(
        conflictChunks));
  }

  @Override
  public boolean resolveConflictChunk(int chunkIndex) throws IOException {
    checkNotClosed();

    AnchoredConflictChunk chunk = conflictChunks.get(chunkIndex);
    if (chunk.isResolved()) {
      /*
       * This chunk can't be resolved because it is already resolved. This can happen if another
       * collaborator resolved the chunk, but this client did not get the notification until they
       * sent their own resolve message.
       */
      return false;
    }
    chunk.markResolved(true);

    logger.debug(String.format("Resolved conflict #%d in file [%s]", chunkIndex, this));

    /*
     * Immediately save the file.
     *
     * TODO: how to store chunk resolution?
     */
    // TODO: Resolve path prior to calling save.
    save(getSavedPath());
    return true;
  }

  @Override
  public boolean hasUnresolvedConflictChunks() {
    return hasUnresolvedConflictChunks(getConflictChunks());
  }

  private static boolean hasUnresolvedConflictChunks(List<? extends ConflictChunk> conflictChunks) {
    for (ConflictChunk conflict : conflictChunks) {
      if (!conflict.isResolved()) {
        return true;
      }
    }
    return false;
  }

  @Override
  public String toString() {
    return resourceId;
  }

  @Override
  public String getSavedPath() {
    return lastSavedPath;
  }
}
