/*
 * Copyright 2003-2016 JetBrains s.r.o.
 *
 * 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 jetbrains.mps.nodefs;

import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.components.ApplicationComponent;
import com.intellij.openapi.vfs.DeprecatedVirtualFileSystem;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.LocalTimeCounter;
import jetbrains.mps.ide.MPSCoreComponents;
import jetbrains.mps.smodel.ModelAccessHelper;
import jetbrains.mps.smodel.RepoListenerRegistrar;
import jetbrains.mps.smodel.SNodePointer;
import jetbrains.mps.smodel.event.NodeChangeCollector;
import jetbrains.mps.util.annotation.ToRemove;
import org.jetbrains.annotations.NonNls;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.mps.openapi.event.AbstractModelChangeEvent;
import org.jetbrains.mps.openapi.event.SNodeAddEvent;
import org.jetbrains.mps.openapi.event.SNodeRemoveEvent;
import org.jetbrains.mps.openapi.event.SPropertyChangeEvent;
import org.jetbrains.mps.openapi.event.SReferenceChangeEvent;
import org.jetbrains.mps.openapi.model.SModel;
import org.jetbrains.mps.openapi.model.SModelReference;
import org.jetbrains.mps.openapi.model.SNode;
import org.jetbrains.mps.openapi.model.SNodeReference;
import org.jetbrains.mps.openapi.module.SModule;
import org.jetbrains.mps.openapi.module.SRepository;
import org.jetbrains.mps.openapi.module.SRepositoryContentAdapter;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;

public final class NodeVirtualFileSystem extends DeprecatedVirtualFileSystem implements ApplicationComponent {

  public static NodeVirtualFileSystem getInstance() {
    return ApplicationManager.getApplication().getComponent(NodeVirtualFileSystem.class);
  }

  public NodeVirtualFileSystem(MPSCoreComponents coreComponents) {
    // FIXME this component shall be ProjectComponent, pass MPSProject.getRepository(); initialize in projectOpened()
    SRepository myRepository = coreComponents.getModuleRepository();
    myGlobalRepoFiles = new RepositoryVirtualFiles(this, myRepository);
    myRepositoryListener = new MyRepositoryListener(myGlobalRepoFiles);
  }

  /*
   * For transition period, left container of virtual files coming from MPSModuleRepository.getInstance(), and use it
   * as default when supplied repository is not found (regardless of whether supplied repo matches MPSModuleRepository instance) for
   * compatibility with existing code, that doesn't manage SRepository well. Shall drop as soon as MPSModuleRepository instance is history
   * (or at least managed and not exposed to user code).
   */
  @ToRemove(version = 3.4)
  private final RepositoryVirtualFiles myGlobalRepoFiles;

  private final Object myRepoVFLock = new Object();
  // I don't expect this collection to grow significantly, hence just List
  private final List<RepositoryVirtualFiles> myPerRepositoryFiles = new CopyOnWriteArrayList<>();
  private final Map<RepositoryVirtualFiles, MyRepositoryListener> myFiles2ListenerMap = new HashMap<>();
  private final SRepositoryContentAdapter myRepositoryListener;
  private boolean myDisposed = false;

  void register(@NotNull RepositoryVirtualFiles repoFiles) {
    MyRepositoryListener listener;
    synchronized (myRepoVFLock) {
      // assert not more than 1 file container per repository
      RepositoryVirtualFiles existing = findForRepository(repoFiles.getRepository());
      if (existing != null) {
        throw new IllegalArgumentException("Attempt to register another VirtualFile container for the same repository");
      }
      // sort of stack, most recent first. just for fun, no hidden assumptions.
      myPerRepositoryFiles.add(0, repoFiles);
      listener = new MyRepositoryListener(repoFiles);
      myFiles2ListenerMap.put(repoFiles, listener);
    }
    new RepoListenerRegistrar(repoFiles.getRepository(), listener).attach();
  }

  void unregister(@NotNull RepositoryVirtualFiles repoFiles) {
    MyRepositoryListener listener;
    synchronized (myRepoVFLock) {
      myPerRepositoryFiles.remove(repoFiles);
      listener = myFiles2ListenerMap.remove(repoFiles);
    }
    if (listener != null) {
      new RepoListenerRegistrar(repoFiles.getRepository(), listener).detach();
    }
  }

  public MPSNodeVirtualFile getFileFor(@NotNull SRepository repository, @NotNull final SNode node) {
    return getFileFor(repository, node.getReference());
  }

  public MPSNodeVirtualFile getFileFor(@NotNull SRepository repository, @NotNull final SNodeReference nodePointer) {
    final RepositoryVirtualFiles rvf = findForRepository(repository);
    return rvf != null ? rvf.getFileFor(nodePointer) : myGlobalRepoFiles.getFileFor(nodePointer);
  }

  public MPSModelVirtualFile getFileFor(@NotNull SRepository repository, @NotNull final SModelReference modelReference) {
    final RepositoryVirtualFiles rvf = findForRepository(repository);
    return rvf != null ? rvf.getFileFor(modelReference) : myGlobalRepoFiles.getFileFor(modelReference);
  }

  @Nullable
  private RepositoryVirtualFiles findForRepository(@NotNull SRepository repo) {
    synchronized (myRepoVFLock) {
      for (RepositoryVirtualFiles rvf : myPerRepositoryFiles) {
        if (repo.equals(rvf.getRepository())) {
          return rvf;
        }
      }
    }
    return null;
  }

  @Override
  @NonNls
  @NotNull
  public String getComponentName() {
    return "MPS File System";
  }

  @Override
  public void initComponent() {
    new RepoListenerRegistrar(myGlobalRepoFiles.getRepository(), myRepositoryListener).attach();
  }

  @Override
  public void disposeComponent() {
    new RepoListenerRegistrar(myGlobalRepoFiles.getRepository(), myRepositoryListener).detach();
    myDisposed = true;
  }

  @Override
  @NotNull
  @NonNls
  public String getProtocol() {
    return "mps";
  }

  @Override
  @Nullable
  public VirtualFile findFileByPath(final @NotNull @NonNls String path) {
    for (RepositoryVirtualFiles rvf : myPerRepositoryFiles) { // going by snapshot here and checking all persisted repositories
      VirtualFile file = new ModelAccessHelper(rvf.getRepository()).runReadAction(() -> {
        synchronized (myRepoVFLock) {
          if (myPerRepositoryFiles.contains(rvf)) { // double check
            return rvf.findFileByPath(path);
          }
          return null;
        }
      });
      if (file != null) {
        return file;
      }
    }
    return new ModelAccessHelper(myGlobalRepoFiles.getRepository()).runReadAction(() -> myGlobalRepoFiles.findFileByPath(path));
  }

  @Override
  public void refresh(boolean asynchronous) {
    // no-op
  }

  @Override
  @Nullable
  public VirtualFile refreshAndFindFileByPath(@NotNull String path) {
    return null;
  }

  private void updateModificationStamp(Collection<MPSNodeVirtualFile> files) {
    // identical timestamp for all roots touched simultaneously
    final long vfsStamp = LocalTimeCounter.currentTime();
    final long localStamp = System.currentTimeMillis();
    for (MPSNodeVirtualFile vf : files) {
      vf.setModificationStamp(vfsStamp);
      vf.setTimeStamp(localStamp);
    }
  }

  private class MyRepositoryListener extends SRepositoryContentAdapter {
    private final RepositoryVirtualFiles myRepoFiles;
    private final NodeChangeCollector myChangeCollector = new NodeChangeCollector();

    /**
     * FIXME the only reason we don't use single listener instance (we can obtain proper SRepository from the change event's model/node)
     * FIXME is that our project repository implementation is not capable of event sending, all events come from global repository.
     *       Thus, it would be impossible to find proper RepositoryVirtualFiles instance. Shall fix ProjectRepository and its base impl
     *       to send events on its own.
     */
    public MyRepositoryListener(RepositoryVirtualFiles repoFiles) {
      myRepoFiles = repoFiles;
    }

    @Override
    protected boolean isIncluded(SModule module) {
      return !module.isReadOnly();
    }

    @Override
    protected void startListening(SModel model) {
      // we listen to SModelListener#modelReplaced
      model.addModelListener(this);
      // we care about node changes
      model.addChangeListener(this);
    }

    @Override
    protected void stopListening(SModel model) {
      model.removeChangeListener(this);
      model.removeModelListener(this);
      forget(model);
    }

    // XXX I keep this method instead of direct access to myRepoFiles field in a desperate hope to have single repo listener some day,
    //     which would pick RVF instance based on model's repository. And that's the reason I check for null return value
    @Nullable
    private RepositoryVirtualFiles findRepoFiles(SModel m) {
      if (m.getRepository() == null) {
        return null;
      }
      return findRepoFiles(m.getRepository());
    }

    @Nullable
    private RepositoryVirtualFiles findRepoFiles(SRepository repository) {
      // TODO single listener instance and find RVF by repo
      return myRepoFiles;
    }

    private void forget(SModel modelDescriptor) {
      final RepositoryVirtualFiles rvf = findRepoFiles(modelDescriptor);
      if (rvf == null) {
        return;
      }
      VFSNotifier vfsNotifier = rvf.getNotifier(new VFSNotifier(rvf));
      vfsNotifier.deleted(rvf.getKnownVirtualFilesIn(modelDescriptor.getReference()));
      vfsNotifier.execute();
    }

    // SModelListener#modelReplaced
    @Override
    public void modelReplaced(SModel md) {
      final RepositoryVirtualFiles rvf = findRepoFiles(md);
      if (rvf == null) {
        return;
      }
      final Collection<MPSNodeVirtualFile> filesInModel = rvf.getKnownVirtualFilesIn(md.getReference());
      updateModificationStamp(filesInModel);

      Collection<MPSNodeVirtualFile> deletedFiles = new ArrayList<MPSNodeVirtualFile>();
      Collection<MPSNodeVirtualFile> changedFiles = new ArrayList<MPSNodeVirtualFile>();
      for (MPSNodeVirtualFile vf : filesInModel) {
        // XXX reconsider vf.getNode() (with SRepository in file construction time), vf.getNode(myRepository) and explicit resolve here
        if (vf.getNode() == null) {
          deletedFiles.add(vf);
        } else {
          changedFiles.add(vf);
        }
      }
      VFSNotifier vfsNotifier = rvf.getNotifier(new VFSNotifier(rvf));
      vfsNotifier.deleted(deletedFiles);
      vfsNotifier.changed(changedFiles);
      vfsNotifier.execute();
    }

    @Override
    public void commandStarted(SRepository repository) {
      myChangeCollector.start();
    }

    @Override
    public void commandFinished(SRepository repository) {
      myChangeCollector.stop();
      final List<AbstractModelChangeEvent> events = myChangeCollector.purge();
      final RepositoryVirtualFiles rvf = findRepoFiles(repository);
      if (rvf == null || events.isEmpty()) {
        return;
      }
      Collection<MPSNodeVirtualFile> deletedFiles = new ArrayList<MPSNodeVirtualFile>();
      Collection<MPSNodeVirtualFile> changedFiles = new ArrayList<MPSNodeVirtualFile>();
      for (AbstractModelChangeEvent evt : events) {
        if (evt instanceof SPropertyChangeEvent) {
          // candidate for rename
          MPSNodeVirtualFile vf = rvf.getVirtualFile(((SPropertyChangeEvent) evt).getNode().getReference());
          if (vf != null) {
            changedFiles.add(vf);
          }
        } else if (evt instanceof SNodeRemoveEvent) {
          // SNode.getReference() for deleted node produces invalid pointer
          MPSNodeVirtualFile vf = rvf.getVirtualFile(new SNodePointer(evt.getModel().getReference(), ((SNodeRemoveEvent) evt).getChild().getNodeId()));
          if (vf != null) {
            deletedFiles.add(vf);
          }
        }
      }
      VFSNotifier vfsNotifier = rvf.getNotifier(new VFSNotifier(rvf));
      vfsNotifier.deleted(deletedFiles);
      vfsNotifier.changed(changedFiles);
      vfsNotifier.execute();
    }

    @Override
    public void propertyChanged(@NotNull SPropertyChangeEvent event) {
      updateFileTimestampOfAffectedNodes(event, event.getNode().getReference(), new SNodePointer(event.getNode().getContainingRoot()));
      myChangeCollector.propertyChanged(event);
    }

    @Override
    public void referenceChanged(@NotNull SReferenceChangeEvent event) {
      updateFileTimestampOfAffectedNodes(event, event.getNode().getReference(), new SNodePointer(event.getNode().getContainingRoot()));
    }

    @Override
    public void nodeAdded(@NotNull SNodeAddEvent event) {
      if (event.isRoot()) {
        // added root of no interest - there could be no file for it yet.
        return;
      }
      final SNode affectedNode = event.getParent();
      updateFileTimestampOfAffectedNodes(event, new SNodePointer(affectedNode), new SNodePointer(affectedNode.getContainingRoot()));
    }

    @Override
    public void nodeRemoved(@NotNull SNodeRemoveEvent event) {
      // SNode.getReference() for deleted node produces invalid pointer
      final SNodeReference removedNode = new SNodePointer(event.getModel().getReference(), event.getChild().getNodeId());
      updateFileTimestampOfAffectedNodes(event, removedNode, event.isRoot() ? removedNode : new SNodePointer(event.getParent().getContainingRoot()));
      myChangeCollector.nodeRemoved(event);
    }

    /*
     * SModelAdapter that used to update timestamps was deliberately extracted out of end of command listener. Guess, either to
     * update TS immediately or to support multiple TS changes within single command. That's why I keep this immediate TS update approach
     * in the new repository listener, too.
     *
     * XXX 1. Do we need to update TS on model imports change? Present openapi listener doesn't support these changes, but old code didn't care either
     * XXX 2. Why don't we update TS of MPSModelVirtualFile?
     */
    private void updateFileTimestampOfAffectedNodes(AbstractModelChangeEvent event, /*not null*/ SNodeReference changed, @Nullable SNodeReference root) {
      final RepositoryVirtualFiles rvf = findRepoFiles(event.getModel());
      if (rvf == null) {
        return;
      }
      ArrayList<MPSNodeVirtualFile> files = new ArrayList<>(2);
      final MPSNodeVirtualFile vf1 = rvf.getVirtualFile(changed);
      if (vf1 != null) {
        files.add(vf1);
      }
      if (root != null && root != changed) {
        MPSNodeVirtualFile vf2 = rvf.getVirtualFile(root);
        if (vf2 != null && vf2 != vf1) {
          files.add(vf2);
        }
      }
      updateModificationStamp(files);
    }
  }

  private class VFSNotifier implements Runnable {
    private final RepositoryVirtualFiles mySource;
    private final Set<MPSNodeVirtualFile> myDeletedFiles = new HashSet<>();
    private final Set<MPSNodeVirtualFile> myChangedFiles = new HashSet<>();
    private final AtomicBoolean myPendingChanges = new AtomicBoolean();

    public VFSNotifier(@NotNull RepositoryVirtualFiles source) {
      mySource = source;
    }

    public synchronized void deleted(Collection<MPSNodeVirtualFile> deletedFiles) {
      if (!deletedFiles.isEmpty()) {
        myPendingChanges.set(true);
      }
      myDeletedFiles.addAll(deletedFiles);
    }

    public synchronized void changed(Collection<MPSNodeVirtualFile> changed) {
      if (!changed.isEmpty()) {
        myPendingChanges.set(true);
      }
      myChangedFiles.addAll(changed);
    }

    /**
     * Asynchronous invocation does not guarantee that node reference will persist in the given repository.
     * However Artem proposed to get rid of SNodeReference#resolve at all.
     */
    public void execute() {
      if (hasPendingNotifications()) {
        mySource.scheduleNotifier(this);
      }
    }

    @Override
    public void run() {
      if (myDisposed) {
        return;
      }
      ArrayList<MPSNodeVirtualFile> deletedFiles;
      ArrayList<MPSNodeVirtualFile> changedFiles;
      synchronized (this) {
        deletedFiles = new ArrayList<>(myDeletedFiles);
        changedFiles = new ArrayList<>(myChangedFiles);
        myDeletedFiles.clear();
        myChangedFiles.clear();
      }

      // notifier is shared, it's possible to get both changed and deleted notification for the same file
      // no reason to report changes for deleted.
      changedFiles.removeAll(deletedFiles);

      for (MPSNodeVirtualFile deletedFile : deletedFiles) {
        fireBeforeFileDeletion(this, deletedFile);
        deletedFile.invalidate();
        fireFileDeleted(this, deletedFile, deletedFile.getName(), null);
      }

      for (MPSNodeVirtualFile changedFile : changedFiles) {
        String oldName = changedFile.getName();
        changedFile.updateFields();
        String newName = changedFile.getName();
        if (!oldName.equals(newName)) {
          // XXX this effectively reverts 0ec4b371f9acef4c82b644dfa3a295961b515efc, I wonder what's the reason not to send file rename events?
          firePropertyChanged(this, changedFile, VirtualFile.PROP_NAME, oldName, newName);
        }
      }
    }

    private boolean hasPendingNotifications() {
      return myPendingChanges.get();
    }
  }
}
