/*
 * Copyright 2000-2017 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 org.jetbrains.plugins.groovy.mvc;

import com.intellij.ProjectTopics;
import com.intellij.codeInsight.actions.ReformatCodeProcessor;
import com.intellij.openapi.application.Application;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.ModalityState;
import com.intellij.openapi.components.AbstractProjectComponent;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.module.ModuleManager;
import com.intellij.openapi.module.ModuleUtilCore;
import com.intellij.openapi.progress.ProcessCanceledException;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.util.ProgressIndicatorUtils;
import com.intellij.openapi.progress.util.ReadTask;
import com.intellij.openapi.project.DumbAwareRunnable;
import com.intellij.openapi.project.ModuleListener;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.*;
import com.intellij.openapi.startup.StartupManager;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.util.Pair;
import com.intellij.openapi.util.SimpleModificationTracker;
import com.intellij.openapi.util.Trinity;
import com.intellij.openapi.vfs.*;
import com.intellij.openapi.vfs.impl.BulkVirtualFileListenerAdapter;
import com.intellij.openapi.wm.ToolWindow;
import com.intellij.openapi.wm.ToolWindowAnchor;
import com.intellij.openapi.wm.ToolWindowEP;
import com.intellij.openapi.wm.ToolWindowManager;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiManager;
import com.intellij.ui.GuiUtils;
import com.intellij.util.concurrency.AppExecutorUtil;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.messages.MessageBusConnection;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;
import org.jetbrains.plugins.groovy.mvc.projectView.MvcToolWindowDescriptor;

import java.util.*;
import java.util.concurrent.ExecutorService;

/**
 * @author peter
 */
public class MvcModuleStructureSynchronizer extends AbstractProjectComponent {
  private static final ExecutorService ourExecutor = AppExecutorUtil.createBoundedApplicationPoolExecutor("MvcModuleStructureSynchronizer pool",1);
  private final Set<Pair<Object, SyncAction>> myOrders = new LinkedHashSet<>();

  private Set<VirtualFile> myPluginRoots = Collections.emptySet();

  private boolean myOutOfModuleDirectoryCreatedActionAdded;

  public static boolean ourGrailsTestFlag;

  private final SimpleModificationTracker myModificationTracker = new SimpleModificationTracker();

  public MvcModuleStructureSynchronizer(Project project) {
    super(project);
  }

  public SimpleModificationTracker getFileAndRootsModificationTracker() {
    return myModificationTracker;
  }

  @Override
  public void initComponent() {
    final MessageBusConnection connection = myProject.getMessageBus().connect();
    connection.subscribe(ProjectTopics.PROJECT_ROOTS, new ModuleRootListener() {
      @Override
      public void rootsChanged(ModuleRootEvent event) {
        myModificationTracker.incModificationCount();
        queue(SyncAction.SyncLibrariesInPluginsModule, myProject);
        queue(SyncAction.UpgradeFramework, myProject);
        queue(SyncAction.CreateAppStructureIfNeeded, myProject);
        queue(SyncAction.UpdateProjectStructure, myProject);
        queue(SyncAction.EnsureRunConfigurationExists, myProject);
        updateProjectViewVisibility();
      }
    });

    connection.subscribe(ProjectTopics.MODULES, new ModuleListener() {
      @Override
      public void moduleAdded(@NotNull Project project, @NotNull Module module) {
        queue(SyncAction.UpdateProjectStructure, module);
        queue(SyncAction.CreateAppStructureIfNeeded, module);
      }
    });

    connection.subscribe(VirtualFileManager.VFS_CHANGES, new BulkVirtualFileListenerAdapter(new VirtualFileListener() {
      @Override
      public void fileCreated(@NotNull final VirtualFileEvent event) {
        myModificationTracker.incModificationCount();

        final VirtualFile file = event.getFile();
        final String fileName = event.getFileName();
        if (MvcModuleStructureUtil.APPLICATION_PROPERTIES.equals(fileName) || isApplicationDirectoryName(fileName)) {
          queue(SyncAction.UpdateProjectStructure, file);
          queue(SyncAction.EnsureRunConfigurationExists, file);
        }
        else if (isLibDirectory(file) || isLibDirectory(event.getParent())) {
          queue(SyncAction.UpdateProjectStructure, file);
        }
        else {
          if (!myProject.isInitialized()) return;

          final Module module = ProjectRootManager.getInstance(myProject).getFileIndex().getModuleForFile(file);

          if (module == null) { // Maybe it is creation of a plugin in plugin directory.
            if (file.isDirectory()) {
              if (myPluginRoots.contains(file.getParent())) {
                queue(SyncAction.UpdateProjectStructure, myProject);
                return;
              }

              if (!myOutOfModuleDirectoryCreatedActionAdded) {
                queue(SyncAction.OutOfModuleDirectoryCreated, myProject);
                myOutOfModuleDirectoryCreatedActionAdded = true;
              }
            }
            return;
          }

          if (!MvcConsole.isUpdatingVfsByConsoleProcess(module)) return;

          final MvcFramework framework = MvcFramework.getInstance(module);
          if (framework == null) return;

          if (framework.isToReformatOnCreation(file) || file.isDirectory()) {
            ApplicationManager.getApplication().invokeLater(() -> {
              if (!file.isValid()) return;
              if (!framework.hasSupport(module)) return;

              final List<VirtualFile> files = new ArrayList<>();

              if (file.isDirectory()) {
                ModuleRootManager.getInstance(module).getFileIndex().iterateContentUnderDirectory(file, new ContentIterator() {
                  @Override
                  public boolean processFile(VirtualFile fileOrDir) {
                    if (!fileOrDir.isDirectory() && framework.isToReformatOnCreation(fileOrDir)) {
                      files.add(file);
                    }
                    return true;
                  }
                });
              }
              else {
                files.add(file);
              }

              PsiManager manager = PsiManager.getInstance(myProject);

              for (VirtualFile virtualFile : files) {
                PsiFile psiFile = manager.findFile(virtualFile);
                if (psiFile != null) {
                  new ReformatCodeProcessor(myProject, psiFile, null, false).run();
                }
              }
            }, module.getDisposed());
          }
        }
      }

      @Override
      public void fileDeleted(@NotNull VirtualFileEvent event) {
        myModificationTracker.incModificationCount();

        final VirtualFile file = event.getFile();
        if (isLibDirectory(file) || isLibDirectory(event.getParent())) {
          queue(SyncAction.UpdateProjectStructure, file);
        }
      }

      @Override
      public void contentsChanged(@NotNull VirtualFileEvent event) {
        final String fileName = event.getFileName();
        if (MvcModuleStructureUtil.APPLICATION_PROPERTIES.equals(fileName)) {
          queue(SyncAction.UpdateProjectStructure, event.getFile());
        }
      }

      @Override
      public void fileMoved(@NotNull VirtualFileMoveEvent event) {
        myModificationTracker.incModificationCount();
      }

      @Override
      public void propertyChanged(@NotNull VirtualFilePropertyEvent event) {
        if (VirtualFile.PROP_NAME.equals(event.getPropertyName())) {
          myModificationTracker.incModificationCount();
        }
      }
    }));
  }

  public static MvcModuleStructureSynchronizer getInstance(Project project) {
    return project.getComponent(MvcModuleStructureSynchronizer.class);
  }

  private static boolean isApplicationDirectoryName(String fileName) {
    for (MvcFramework framework : MvcFramework.EP_NAME.getExtensions()) {
      if (framework.getApplicationDirectoryName().equals(fileName)) {
        return true;
      }
    }
    return false;
  }

  private static boolean isLibDirectory(@Nullable final VirtualFile file) {
    return file != null && "lib".equals(file.getName());
  }

  @Override
  public void projectOpened() {
    queue(SyncAction.UpdateProjectStructure, myProject);
    queue(SyncAction.EnsureRunConfigurationExists, myProject);
    queue(SyncAction.UpgradeFramework, myProject);
    queue(SyncAction.CreateAppStructureIfNeeded, myProject);
  }

  public void queue(SyncAction action, Object on) {
    ApplicationManager.getApplication().assertIsDispatchThread();
    if (myProject.isDisposed()) return;

    boolean shouldSchedule;
    synchronized (myOrders) {
      shouldSchedule = myOrders.isEmpty();
      myOrders.add(Pair.create(on, action));
    }
    if (shouldSchedule) {
      StartupManager.getInstance(myProject).runWhenProjectIsInitialized(new DumbAwareRunnable() {
        @Override
        public void run() {
          scheduleRunActions();
        }
      });
    }
  }

  private void scheduleRunActions() {
    if (myProject.isDisposed()) return;

    final Application app = ApplicationManager.getApplication();
    if (app.isUnitTestMode()) {
      if (ourGrailsTestFlag && !myProject.isInitialized()) {
        runActions(computeRawActions(takeOrderSnapshot()));
      }
      return;
    }

    final Set<Pair<Object, SyncAction>> orderSnapshot = takeOrderSnapshot();
    ReadTask task = new ReadTask() {

      @Nullable
      @Override
      public Continuation performInReadAction(@NotNull final ProgressIndicator indicator) throws ProcessCanceledException {
        final Set<Trinity<Module, SyncAction, MvcFramework>> actions = isUpToDate() ? computeRawActions(orderSnapshot)
                                                                                    : Collections.emptySet();
        return new Continuation(() -> {
          if (isUpToDate()) {
            runActions(actions);
          }
          else if (!indicator.isCanceled()) {
            scheduleRunActions();
          }
        }, ModalityState.NON_MODAL);
      }

      @Override
      public void onCanceled(@NotNull ProgressIndicator indicator) {
        scheduleRunActions();
      }

      private boolean isUpToDate() {
        return !myProject.isDisposed() && orderSnapshot.equals(takeOrderSnapshot());
      }
    };
    GuiUtils.invokeLaterIfNeeded(() -> ProgressIndicatorUtils.scheduleWithWriteActionPriority(ourExecutor, task), ModalityState.NON_MODAL);
  }

  private LinkedHashSet<Pair<Object, SyncAction>> takeOrderSnapshot() {
    synchronized (myOrders) {
      return new LinkedHashSet<>(myOrders);
    }
  }

  @NotNull
  private List<Module> determineModuleBySyncActionObject(Object o) {
    if (o instanceof Module) {
      return Collections.singletonList((Module)o);
    }
    if (o instanceof Project) {
      return Arrays.asList(ModuleManager.getInstance((Project)o).getModules());
    }
    if (o instanceof VirtualFile) {
      final VirtualFile file = (VirtualFile)o;
      if (file.isValid()) {
        final Module module = ModuleUtilCore.findModuleForFile(file, myProject);
        if (module == null) {
          return Collections.emptyList();
        }

        return Collections.singletonList(module);
      }
    }
    return Collections.emptyList();
  }

  @TestOnly
  public static void forceUpdateProject(Project project) {
    MvcModuleStructureSynchronizer instance = project.getComponent(MvcModuleStructureSynchronizer.class);
    instance.getFileAndRootsModificationTracker().incModificationCount();
    instance.runActions(instance.computeRawActions(instance.takeOrderSnapshot()));
  }

  private void runActions(Set<Trinity<Module, SyncAction, MvcFramework>> actions) {
    try {
      boolean isProjectStructureUpdated = false;

      for (final Trinity<Module, SyncAction, MvcFramework> rawAction : actions) {
        final Module module = rawAction.first;
        if (module.isDisposed()) {
          continue;
        }

        if (rawAction.second == SyncAction.UpdateProjectStructure && rawAction.third.updatesWholeProject()) {
          if (isProjectStructureUpdated) continue;
          isProjectStructureUpdated = true;
        }

        rawAction.second.doAction(module, rawAction.third);
      }
    }
    finally {
      // if there were any actions added during performSyncAction, clear them too
      // all needed actions are already added to buffer and have thus been performed
      // otherwise you may get repetitive 'run create-app?' questions
      synchronized (myOrders) {
        myOrders.clear();
      }
    }
  }

  private Set<Trinity<Module, SyncAction, MvcFramework>> computeRawActions(Set<Pair<Object, SyncAction>> actions) {
    //get module by object and kill duplicates
    final Set<Trinity<Module, SyncAction, MvcFramework>> rawActions = new LinkedHashSet<>();
    for (final Pair<Object, SyncAction> pair : actions) {
      for (Module module : determineModuleBySyncActionObject(pair.first)) {
        if (!module.isDisposed()) {
          final MvcFramework framework = (pair.second == SyncAction.CreateAppStructureIfNeeded)
                                         ? MvcFramework.getInstanceBySdk(module)
                                         : MvcFramework.getInstance(module);

          if (framework != null && !framework.isAuxModule(module)) {
            rawActions.add(Trinity.create(module, pair.second, framework));
          }
        }
      }
    }
    return rawActions;
  }

  public enum SyncAction {
    SyncLibrariesInPluginsModule {
      @Override
      void doAction(Module module, MvcFramework framework) {
        if (MvcModuleStructureUtil.isEnabledStructureUpdate()) {
          framework.syncSdkAndLibrariesInPluginsModule(module);
        }
      }
    },

    UpgradeFramework {
      @Override
      void doAction(Module module, MvcFramework framework) {
        framework.upgradeFramework(module);
      }
    },

    CreateAppStructureIfNeeded {
      @Override
      void doAction(Module module, MvcFramework framework) {
        framework.createApplicationIfNeeded(module);
      }
    },

    UpdateProjectStructure {
      @Override
      void doAction(final Module module, final MvcFramework framework) {
        framework.updateProjectStructure(module);
      }
    },

    EnsureRunConfigurationExists {
      @Override
      void doAction(Module module, MvcFramework framework) {
        framework.ensureRunConfigurationExists(module);
      }
    },

    OutOfModuleDirectoryCreated {
      @Override
      void doAction(Module module, MvcFramework framework) {
        final Project project = module.getProject();
        final MvcModuleStructureSynchronizer mvcModuleStructureSynchronizer = getInstance(project);

        if (mvcModuleStructureSynchronizer.myOutOfModuleDirectoryCreatedActionAdded) {
          mvcModuleStructureSynchronizer.myOutOfModuleDirectoryCreatedActionAdded = false;

          Set<VirtualFile> roots = new HashSet<>();

          for (String rootPath : MvcWatchedRootProvider.getRootsToWatch(project)) {
            ContainerUtil.addIfNotNull(roots, LocalFileSystem.getInstance().findFileByPath(rootPath));
          }

          if (!roots.equals(mvcModuleStructureSynchronizer.myPluginRoots)) {
            mvcModuleStructureSynchronizer.myPluginRoots = roots;
            ApplicationManager.getApplication().invokeLater(() -> mvcModuleStructureSynchronizer.queue(UpdateProjectStructure, project));
          }
        }
      }
    };

    abstract void doAction(Module module, MvcFramework framework);
  }

  private void updateProjectViewVisibility() {
    if (ApplicationManager.getApplication().isUnitTestMode()) return;
    StartupManager.getInstance(myProject).runWhenProjectIsInitialized(new DumbAwareRunnable() {
      @Override
      public void run() {
        ApplicationManager.getApplication().invokeLater(() -> {
          if (myProject.isDisposed()) return;

          for (ToolWindowEP ep : ToolWindowEP.EP_NAME.getExtensions()) {
            if (MvcToolWindowDescriptor.class.isAssignableFrom(ep.getFactoryClass())) {
              MvcToolWindowDescriptor descriptor = (MvcToolWindowDescriptor)ep.getToolWindowFactory();
              String id = descriptor.getToolWindowId();
              boolean shouldShow = descriptor.value(myProject);

              ToolWindowManager toolWindowManager = ToolWindowManager.getInstance(myProject);

              ToolWindow toolWindow = toolWindowManager.getToolWindow(id);

              if (shouldShow && toolWindow == null) {
                toolWindow = toolWindowManager.registerToolWindow(id, true, ToolWindowAnchor.LEFT, myProject, true);
                toolWindow.setIcon(descriptor.getFramework().getToolWindowIcon());
                descriptor.createToolWindowContent(myProject, toolWindow);
              }
              else if (!shouldShow && toolWindow != null) {
                toolWindowManager.unregisterToolWindow(id);
                Disposer.dispose(toolWindow.getContentManager());
              }
            }
          }
        });
      }
    });
  }

}
