/*
 * 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.project;

import com.intellij.ide.util.gotoByName.ChooseByNamePopup;
import com.intellij.ide.util.gotoByName.ChooseByNamePopupComponent.Callback;
import com.intellij.openapi.actionSystem.ShortcutSet;
import com.intellij.openapi.application.ModalityState;
import jetbrains.mps.module.ReloadableModule;
import jetbrains.mps.project.structure.modules.ModuleReference;
import jetbrains.mps.scope.ConditionalScope;
import jetbrains.mps.smodel.BootstrapLanguages;
import jetbrains.mps.smodel.Language;
import jetbrains.mps.smodel.ModelAccessHelper;
import jetbrains.mps.smodel.SLanguageHierarchy;
import jetbrains.mps.smodel.SModelOperations;
import jetbrains.mps.smodel.adapter.ids.MetaIdHelper;
import jetbrains.mps.smodel.adapter.ids.SLanguageId;
import jetbrains.mps.smodel.adapter.structure.MetaAdapterFactory;
import jetbrains.mps.smodel.language.LanguageRegistry;
import jetbrains.mps.util.Reference;
import jetbrains.mps.workbench.choose.ChooseByNameData;
import jetbrains.mps.workbench.choose.LanguagesPresentation;
import jetbrains.mps.workbench.goTo.ui.MpsPopupFactory;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.mps.openapi.language.SLanguage;
import org.jetbrains.mps.openapi.model.SModel;
import org.jetbrains.mps.openapi.module.SModule;
import org.jetbrains.mps.openapi.module.SModuleReference;
import org.jetbrains.mps.openapi.module.SRepository;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

/**
 * Facility to interoperate with user to add a new used language to a model/devkit.
 * Responsible to collect user input and to access model/module internals to update respectively.
 * <p/>
 * Since this class shows UI, it takes over responsibility to control model access and executing appropriate commands.
 * <p/>
 * Configure the helper first ({@link #setOnCloseActivity(Runnable)}, {@link #setShortcut(ShortcutSet)}, then perform
 * actual selection and modification with {@link #addUsedLanguage(SModel)} or {@link #addExportedLanguage(DevKit)}
 *
 * @author Artem Tikhomirov
 * @since 3.4
 */
public final class LanguageImportHelper {
  private final MPSProject myProject;
  private Runnable myOnCloseActivity;
  private ShortcutSet myShortcut;
  private Interaction myInteraction;

  public LanguageImportHelper(@NotNull MPSProject project) {
    myProject = project;
    myInteraction = new UiInteraction();
  }

  /**
   * Construct import helper with a custom interaction, typically non-ui, which is the default. Used for testing.
   *
   * @param interaction Custom interaction object
   */
  public LanguageImportHelper(@NotNull MPSProject project, @NotNull Interaction interaction) {
    myProject = project;
    myInteraction = interaction;
  }

  /**
   * This is the way it used to be. Its use suggests ChooseByNamePopupComponent.invoke() returns immediately, and we need
   * a mechanism to perform an action only once dialog to pick a language closes.
   * <p/>
   * NOTE, this is configuration method and shall be invoked prior to {@link #addExportedLanguage(DevKit)} or {@link #addUsedLanguage(SModel)} calls
   *
   * @param runnable code to execute once language to import has been picked or selection dialog has been closed
   * @return {@code this} for convenience
   */
  @SuppressWarnings("unused") // in use from idea plugin, MakeDirAModel.
  public LanguageImportHelper setOnCloseActivity(@Nullable Runnable runnable) {
    myOnCloseActivity = runnable;
    return this;
  }

  /**
   * For a dialog to pick a language, one might want to override keyboard shortcut
   * to switch between global and package scope (e.g. to match that of invoking action).
   * <p/>
   * NOTE, this is configuration method and shall be invoked prior to {@link #addExportedLanguage(DevKit)} or {@link #addUsedLanguage(SModel)} calls
   *
   * @return {@code this} for convenience
   */
  public LanguageImportHelper setShortcut(@Nullable ShortcutSet shortcut) {
    myShortcut = shortcut;
    return this;
  }

  /**
   * Use this method to add a language to set of languages exposed by the devkit.
   *
   * @param devkit affected devkit, the one to add new export
   */
  public void addExportedLanguage(@NotNull final DevKit devkit) {
    chooseLanguage(new jetbrains.mps.util.Callback<SLanguage>() {
      @Override
      public void call(final SLanguage param) {
        final Set<SLanguage> importCandidates = new ModelAccessHelper(myProject.getModelAccess()).runWriteAction(() -> {
          Set<SLanguage> langs = getExtendedLanguages(param);

          final Collection<SLanguage> alreadyImported = new HashSet<>();
          for (SLanguage language : devkit.getAllExportedLanguageIds()) {
            alreadyImported.add(language);
          }
          langs.removeAll(alreadyImported);
          return langs;
        });

        final Set<SLanguage> toImport = new HashSet<>();

        if (!importCandidates.isEmpty()) {
          toImport.addAll(chooseModulesToImport(importCandidates));
        }

      /*TODO: rewrite importing:
      * If module is already imported itself/by devkit either do nothing or show message.
      * Better try to filter all such imports in the first place.
      * */
        toImport.add(param);

        myProject.getModelAccess().executeCommand(new Runnable() {
          @Override
          public void run() {
            for (SLanguage li : toImport) {
              SModuleReference ref = moduleRefForLanguage(li);
              SModule lang = ref.resolve(myProject.getRepository());
              if (lang instanceof Language) {
                devkit.getModuleDescriptor().getExportedLanguages().add(ref);
                devkit.setChanged();
              }
            }
          }

          // FIXME copied from SModelDescriptorStub#moduleRefForLanguage as there's no way to go backwards from newer concepts
          private SModuleReference moduleRefForLanguage(SLanguage lang) {
            String name = lang.getQualifiedName();
            SLanguageId id = MetaIdHelper.getLanguage(lang);
            ModuleId moduleId = ModuleId.regular(id.getIdValue());
            return new ModuleReference(name, moduleId);
          }

        });
      }
    });
  }

  /**
   * Use this method to record new used language in a model.
   *
   * @param model affected model, the one to get another language to use
   */
  public void addUsedLanguage(@NotNull final SModel model) {
    chooseLanguage(new jetbrains.mps.util.Callback<SLanguage>() {
      @Override
      public void call(final SLanguage param) {
        final Set<SLanguage> importCandidates = new ModelAccessHelper(myProject.getModelAccess()).runWriteAction(() -> {
          Set<SLanguage> langs = getExtendedLanguages(param);

          // XXX likely, all imported + all visible (i.e. those extended) shall be considered -
          // there's no need to import otherwise visible language
          final Set<SLanguage> alreadyImported = SModelOperations.getAllLanguageImports(model);
          langs.removeAll(alreadyImported);
          return langs;
        });

        final Set<SLanguage> toImport = new HashSet<>();

        if (!importCandidates.isEmpty()) {
          toImport.addAll(chooseModulesToImport(importCandidates));
        }

      /*TODO: rewrite importing:
      * If module is already imported itself/by devkit either do nothing or show message.
      * Better try to filter all such imports in the first place.
      * */
        toImport.add(param);

        myProject.getModelAccess().executeCommand(() -> {
          boolean reload = false;
          final boolean reloadableModule = model.getModule() instanceof ReloadableModule;
          Set<SLanguage> existingUsedLanguages = reloadableModule ? model.getModule().getUsedLanguages() : Collections.<SLanguage>emptySet();
          for (SLanguage rtLanguage : toImport) {
            if (!existingUsedLanguages.contains(rtLanguage)) {
              // If model gets the new import, then its module would get new dependency
              reload = true;
            }
            ((jetbrains.mps.smodel.SModelInternal) model).addLanguage(rtLanguage);
          }
          if (reloadableModule && reload) {
            ((ReloadableModule) model.getModule()).reload();
          }
        });
      }
    });
  }

  /*package*/ Set<SLanguage> getExtendedLanguages(SLanguage param) {
    final LanguageRegistry languageRegistry = LanguageRegistry.getInstance(myProject.getRepository());
    SLanguageHierarchy langHierarchy = new SLanguageHierarchy(languageRegistry, Collections.singleton(param));
    Set<SLanguage> langs = langHierarchy.getExtended();
    langs.remove(param);
    // todo: ! ?
    //this is added in language implicitly, so we don't show this import
    langs.remove(BootstrapLanguages.getLangCore());
    return langs;
  }

  @NotNull
  /*package*/ Set<SLanguage> chooseModulesToImport(Set<SLanguage> candidates) {
    return myInteraction.chooseAdditionalLanguages(candidates);
  }


  private void chooseLanguage(final jetbrains.mps.util.Callback<SLanguage> addLanguageAction) {
    final SRepository repo = myProject.getRepository();
    final Reference<Collection<SLanguage>> projectScope = new Reference<>();
    final Reference<Collection<SLanguage>> globalScope = new Reference<>();

    repo.getModelAccess().runReadAction(() -> {
      ArrayList<SLanguage> projectLanguages = new ArrayList<>(20);
      for (SModule m : new ConditionalScope(myProject.getScope(), new ModuleInstanceCondition(Language.class), null).getModules()) {
        assert m instanceof Language;
        projectLanguages.add(MetaAdapterFactory.getLanguage(m.getModuleReference()));
      }
      projectScope.set(projectLanguages);
      globalScope.set(LanguageRegistry.getInstance(repo).getAllLanguages());
    });

    ChooseByNameData<SLanguage> gotoData = new ChooseByNameData<>(new LanguagesPresentation());
    gotoData.setScope(projectScope.get(), globalScope.get());
    gotoData.derivePrompts("language").setPrompts("Import language:", gotoData.getNotFoundMessage(), gotoData.getNotInMessage());

    // we used to allow multiple selection, but didn't handle it in any special way
    // (each selected language would trigger own extra dialog to import extended, which is odd)
    myInteraction.chooseLanguage(gotoData, new Callback() {
      private SLanguage myLanguage;

      /**
       * Just save chosen element here.
       * <br>
       * Language will be added late in {@link #onClose()}.
       * */
      @Override
      public void elementChosen(Object element) {
        if (element instanceof SLanguage) {
          myLanguage = (SLanguage) element;
        }
      }

      /**
       * Clients expect {@code myOnCloseActivity.run()} will be executed regardless of selection (i.e. when user canceled a dialog).
       * <br>
       * This works because of methods call order contract in {@link com.intellij.ide.util.gotoByName.ChooseByNamePopupComponent.Callback}:
       * {@link Callback#elementChosen(Object)} called before {@link Callback#onClose()}
       * <p/>
       * For more information see <a href="https://youtrack.jetbrains.com/issue/IDEA-155319">IDEA-155319</a>
       * */
      @Override
      public void onClose() {
        if (myLanguage != null) {
          addLanguageAction.call(myLanguage);
        }

        if (myOnCloseActivity != null) {
          myOnCloseActivity.run();
        }
      }
    });
  }

  /**
   * Instance of this class behaves like a user who uses this import helper.
   * It answers this import helper's questions as to what language to import and what additional languages to import afterwards.
   * Interaction via showing dialog windows in a special case.
   * This class is used to make LanguageImportHelper testable in headless mode.
   */
  public interface Interaction {
    void chooseLanguage(ChooseByNameData<SLanguage> model, Callback addLanguageAction);

    Set<SLanguage> chooseAdditionalLanguages(Set<SLanguage> candidates);
  }

  private class UiInteraction implements Interaction {
    @Override
    public void chooseLanguage(ChooseByNameData<SLanguage> model, Callback addLanguageAction) {
      final ChooseByNamePopup popup = MpsPopupFactory.createPackagePopup(myProject.getProject(), model, null);
      if (myShortcut != null) {
        popup.setCheckBoxShortcut(myShortcut);
      }
      // we used to allow multiple selection, but didn't handle it in any special way
      // (each selected language would trigger own extra dialog to import extended, which is odd)
      popup.invoke(addLanguageAction, ModalityState.current(), false);
    }

    @Override
    public Set<SLanguage> chooseAdditionalLanguages(Set<SLanguage> candidates) {
      SelectLanguagesDialog dialog = new SelectLanguagesDialog(myProject.getProject(), candidates);
      dialog.show();
      if (dialog.isOK()) {
        return dialog.getSelectedModules();
      }
      return Collections.emptySet();
    }
  }
}
