/*
 * Copyright 2003-2015 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.persistence;

import jetbrains.mps.extapi.model.SModelBase;
import jetbrains.mps.extapi.model.SModelData;
import jetbrains.mps.extapi.persistence.datasource.PreinstalledDataSourceTypes;
import jetbrains.mps.persistence.MetaModelInfoProvider.MetaInfoLoadingOption;
import jetbrains.mps.persistence.MetaModelInfoProvider.RegularMetaModelInfo;
import jetbrains.mps.persistence.MetaModelInfoProvider.StuffedMetaModelInfo;
import jetbrains.mps.project.MPSExtentions;
import jetbrains.mps.smodel.DefaultSModel;
import jetbrains.mps.smodel.DefaultSModelDescriptor;
import jetbrains.mps.smodel.SModelHeader;
import jetbrains.mps.smodel.SModelId;
import jetbrains.mps.smodel.loading.ModelLoadResult;
import jetbrains.mps.smodel.loading.ModelLoadingState;
import jetbrains.mps.smodel.persistence.def.ModelPersistence;
import jetbrains.mps.smodel.persistence.def.ModelReadException;
import jetbrains.mps.util.FileUtil;
import jetbrains.mps.util.annotation.ToRemove;
import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.mps.annotations.Internal;
import org.jetbrains.mps.openapi.model.SModel;
import org.jetbrains.mps.openapi.model.SModelName;
import org.jetbrains.mps.openapi.model.SModelReference;
import org.jetbrains.mps.openapi.persistence.DataSource;
import org.jetbrains.mps.openapi.persistence.ModelCreationException;
import org.jetbrains.mps.openapi.persistence.ModelFactory;
import org.jetbrains.mps.openapi.persistence.ModelFactoryType;
import org.jetbrains.mps.openapi.persistence.ModelLoadException;
import org.jetbrains.mps.openapi.persistence.ModelLoadingOption;
import org.jetbrains.mps.openapi.persistence.MultiStreamDataSource;
import org.jetbrains.mps.openapi.persistence.PersistenceFacade;
import org.jetbrains.mps.openapi.persistence.StreamDataSource;
import org.jetbrains.mps.openapi.persistence.UnsupportedDataSourceException;
import org.jetbrains.mps.openapi.persistence.datasource.DataSourceType;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
 * Factory for models stored in .mps files.
 */
public class DefaultModelPersistence implements ModelFactory, IndexAwareModelFactory {
  private static final Logger LOG = LogManager.getLogger(DefaultModelPersistence.class);

  /**
   * Boolean option for model loading, indicates loaded model doesn't care about implementation node.
   * For the time being, implementation node is the one with appropriate ConceptKind (designated according to concept's implemented interfaces).
   *
   * @deprecated use {@link ContentLoadingExtentOptions} instead
   */
  @ToRemove(version = 3.7)
  @Deprecated
  public static final String OPTION_STRIP_IMPLEMENTATION = "load-without-impl";

  /**
   * Boolean option for model loading, indicates loaded model cares about its interface aspects only.
   *
   * @deprecated use {@link ContentLoadingExtentOptions} instead
   */
  @ToRemove(version = 3.7)
  @Deprecated
  public static final String OPTION_INTERFACE_ONLY = "load-interface-only";

  public enum ContentLoadingExtentOptions implements ModelLoadingOption {
    STRIP_IMPLEMENTATION,
    INTERFACE_ONLY
  }

  @Internal
  public DefaultModelPersistence() {
    // do not delete, it is a java service
  }

  @NotNull
  @Override
  public SModel load(@NotNull DataSource dataSource, @NotNull Map<String, String> options) throws IOException {
    List<ModelLoadingOption> newOptions = new ArrayList<>();
    if (Boolean.parseBoolean(options.get(MetaModelInfoProvider.OPTION_KEEP_READ_METAINFO))) {
      newOptions.add(MetaInfoLoadingOption.KEEP_READ);
    }
    if (options.containsKey(OPTION_STRIP_IMPLEMENTATION) && Boolean.parseBoolean(options.get(OPTION_STRIP_IMPLEMENTATION))) {
      newOptions.add(ContentLoadingExtentOptions.STRIP_IMPLEMENTATION);
    } else if (options.containsKey(OPTION_INTERFACE_ONLY) && Boolean.parseBoolean(options.get(OPTION_INTERFACE_ONLY))) {
      newOptions.add(ContentLoadingExtentOptions.INTERFACE_ONLY);
    }
    try {
      return load(dataSource, newOptions.toArray(new ModelLoadingOption[newOptions.size()]));
    } catch (ModelLoadException e) {
      throw new IOException(e);
    }
  }

  @NotNull
  @Override
  public SModel create(@NotNull DataSource dataSource, @NotNull Map<String, String> options) throws IOException {
    String modelName = options.get(OPTION_MODELNAME);
    if (modelName == null) {
      throw new IOException("modelName is not provided");
    }
    try {
      return create(dataSource, new SModelName(modelName));
    } catch (ModelCreationException e) {
      throw new IOException(e);
    }
  }

  @Override
  public boolean canCreate(@NotNull DataSource dataSource, @NotNull Map<String, String> options) {
    return dataSource instanceof StreamDataSource;
  }

  @Override
  public boolean supports(@NotNull DataSource dataSource) {
    return dataSource instanceof StreamDataSource;
  }

  @NotNull
  @Override
  public SModel create(@NotNull DataSource dataSource,
                       @NotNull SModelName modelName,
                       @NotNull ModelLoadingOption... options) throws UnsupportedDataSourceException,
                                                                      ModelCreationException {
    if (!(supports(dataSource))) {
      throw new UnsupportedDataSourceException(dataSource);
    }

    final SModelHeader header = SModelHeader.create(ModelPersistence.LAST_VERSION);
    final SModelReference modelReference = PersistenceFacade.getInstance().createModelReference(null, SModelId.generate(), modelName.getValue());
    header.setModelReference(modelReference);
    final DefaultSModelDescriptor rv = new DefaultSModelDescriptor(new PersistenceFacility(this, (StreamDataSource) dataSource), header);
    // Hack to ensure newly created model is indeed empty. Otherwise, with StreamDataSource pointing to existing model stream, an attempt to
    // do anything with the model triggers loading and the model get all the data. Two approaches deemed reasonable to tackle the issue:
    // (a) enforce clear empty model (why would anyone call #create() then)
    // (b) fail with error (too brutal?)
    // Another alternative considered is to tolerate any DataSource in DefaultSModelDescriptor (or its persistence counterpart), so that
    // one can create an empty model with NullDataSource, and later save with a proper DataSource (which yields more job to client and makes him
    // question why SModel.save() is there). This task is reasonable regardless of final approach taken, but would take more effort, hence the hack.
    if (dataSource.getTimestamp() != -1) { // chances are there's something in the stream already
      rv.replace(new DefaultSModel(modelReference, header)); // model state is FULLY_LOADED, DataSource won't get read
    }
    return rv;
  }

  @NotNull
  @Override
  public SModel load(@NotNull DataSource dataSource, @NotNull ModelLoadingOption... options) throws UnsupportedDataSourceException,
                                                                                                    ModelLoadException {
    if (!(dataSource instanceof StreamDataSource)) {
      throw new UnsupportedDataSourceException(dataSource);
    }

    StreamDataSource source = (StreamDataSource) dataSource;
    PersistenceFacility persistenceFacility = new PersistenceFacility(this, source);
    SModelHeader header = readHeader(dataSource, source, persistenceFacility);
    LOG.debug("Getting model " + header.getModelReference() + " from " + dataSource.getLocation());

    if (Arrays.asList(options).contains(MetaInfoLoadingOption.KEEP_READ)) {
      header.setMetaInfoProvider(new StuffedMetaModelInfo(new RegularMetaModelInfo(header.getModelReference())));
    }

    // If there are any load options, process them and fill the model with desired model data, otherwise return a lightweight descriptor.
    final DefaultSModelDescriptor resultingModel = new DefaultSModelDescriptor(persistenceFacility, header);
    ModelLoadingState loadingLevel = detectLoadingLevel(options);
    readModelUpToLevel(dataSource, persistenceFacility, header, resultingModel, loadingLevel);
    return resultingModel;
  }

  private void readModelUpToLevel(@NotNull DataSource dataSource,
                                  PersistenceFacility persistenceFacility,
                                  SModelHeader header,
                                  DefaultSModelDescriptor rv,
                                  ModelLoadingState loadingLevel) throws ModelLoadException {
    if (loadingLevel != null) {
      try {
        jetbrains.mps.smodel.SModel md = persistenceFacility.readModel(header, loadingLevel).getModel();
        rv.replace(md);
      } catch (ModelReadException e) {
        LOG.error("Can't read model: ", e);
        throw new ModelLoadException("Can't read a model from the '" + dataSource + "'", Collections.emptyList(), e);
      }
    }
  }

  @NotNull
  private SModelHeader readHeader(@NotNull DataSource dataSource, StreamDataSource source, PersistenceFacility pf) throws ModelLoadException {
    SModelHeader header;
    try {
      header = pf.readHeader();
    } catch (ModelReadException e) {
      LOG.error("Can't read model: ", e);
      throw new ModelLoadException("Can't read model header from the '" + dataSource + "'", Collections.emptyList(), e);
    }
    if (header.getModelReference() == null) {
      throw new ModelLoadException("Could not find model reference in the model header while loading from the " + source);
    }
    return header;
  }

  /**
   * An alternative to replace() method call (which is hacky) is to expose UpdateableModel field from LazyEditableSModelBase and use
   * UpdateableModel#getModel(ModelLoadingState) instead to ensure model is loaded to desired state.
   * However, not sure subsequent access to model won't trigger full load anyway, thus replace() which indicates supplied state is 'FULLY LOADED'
   * might be the right (hacky, nonetheless) solution.
   * [atikhomirov]
   */
  @Nullable
  private ModelLoadingState detectLoadingLevel(@NotNull ModelLoadingOption[] options) {
    ModelLoadingState loadingLevel = null;
    if (Arrays.asList(options).contains(ContentLoadingExtentOptions.STRIP_IMPLEMENTATION)) {
      loadingLevel = ModelLoadingState.NO_IMPLEMENTATION;
    } else if (Arrays.asList(options).contains(ContentLoadingExtentOptions.INTERFACE_ONLY)) {
      loadingLevel = ModelLoadingState.INTERFACE_LOADED;
    }
    return loadingLevel;
  }

  @Override
  public boolean needsUpgrade(@NotNull DataSource dataSource) throws IOException {
    if (!(dataSource instanceof StreamDataSource)) {
      throw new UnsupportedDataSourceException(dataSource);
    }
    try {

      SModelHeader header = ModelPersistence.loadDescriptor((StreamDataSource) dataSource);
      return header.getPersistenceVersion() < ModelPersistence.LAST_VERSION;
    } catch (ModelReadException ex) {
      throw new IOException(ex);
    }
  }

  @Override
  public void upgrade(@NotNull DataSource dataSource) throws IOException {
    if (!(dataSource instanceof StreamDataSource)) {
      throw new UnsupportedDataSourceException(dataSource);
    }

    StreamDataSource source = (StreamDataSource) dataSource;
    try {
      DefaultSModel model = ModelPersistence.readModel(source, false);
      ModelPersistence.saveModel(model, source, ModelPersistence.LAST_VERSION);
    } catch (ModelReadException ex) {
      throw new IOException(ex.getMessage(), ex);
    }
  }

  @Override
  public void save(@NotNull SModel model, @NotNull DataSource dataSource) throws IOException {
    if (!(dataSource instanceof StreamDataSource)) {
      throw new UnsupportedDataSourceException(dataSource);
    }
    int persistenceVersion = -1;
    if (model instanceof PersistenceVersionAware) {
      persistenceVersion = ((PersistenceVersionAware) model).getPersistenceVersion();
    }
    if (persistenceVersion == -1) {
      persistenceVersion = ModelPersistence.LAST_VERSION;
    }

    ModelPersistence.saveModel(((SModelBase) model).getSModel(), (StreamDataSource) dataSource, persistenceVersion);
  }

  @Override
  public void index(@NotNull InputStream input, @NotNull Callback callback) throws IOException {
    ModelPersistence.index(input, callback);
  }

  @Override
  public SModelData parseSingleStream(@NotNull String name, @NotNull InputStream input) throws IOException {
    return ModelPersistence.getModelData(input);
  }

  @Override
  public boolean isBinary() {
    return false;
  }

  @Override
  public String getFileExtension() {
    return MPSExtentions.MODEL;
  }

  @NotNull
  @Override
  public String getFormatTitle() {
    return "Universal XML-based format";
  }

  @NotNull
  @Override
  public ModelFactoryType getType() {
    return PreinstalledModelFactoryTypes.PLAIN_XML;
  }

  @NotNull
  @Override
  public List<DataSourceType> getPreferredDataSourceTypes() {
    return Collections.singletonList(PreinstalledDataSourceTypes.MPS);
  }

  public static Map<String, String> getDigestMap(@NotNull MultiStreamDataSource source, String streamName) {
    InputStream is = null;
    try {
      is = source.openInputStream(streamName);
      return getDigestMap(new InputStreamReader(is, FileUtil.DEFAULT_CHARSET));
    } catch (IOException e) {
      /* ignore */
    } finally {
      FileUtil.closeFileSafe(is);
    }
    return null;
  }

  public static Map<String, String> getDigestMap(@NotNull StreamDataSource source) {
    InputStream is = null;
    try {
      is = source.openInputStream();
      return getDigestMap(new InputStreamReader(is, FileUtil.DEFAULT_CHARSET));
    } catch (IOException e) {
      /* ignore */
    } finally {
      FileUtil.closeFileSafe(is);
    }
    return null;
  }

  public static Map<String, String> getDigestMap(Reader input) {
    try {
      return ModelPersistence.calculateHashes(FileUtil.read(input));
    } catch (ModelReadException e) {
      return null;
    }
  }

  /**
   * hack, @see BinaryModelPersistence#createFromHeader for details
   */
  public static SModel createFromHeader(@NotNull SModelHeader header, @NotNull StreamDataSource dataSource) {
    final ModelFactory modelFactory = PersistenceFacade.getInstance().getModelFactory(MPSExtentions.MODEL);
    assert modelFactory instanceof DefaultModelPersistence;
    return new DefaultSModelDescriptor(new PersistenceFacility((DefaultModelPersistence) modelFactory, dataSource), header.createCopy());
  }

  private static class PersistenceFacility extends LazyLoadFacility {
    /*package*/ PersistenceFacility(DefaultModelPersistence modelFactory, StreamDataSource dataSource) {
      super(modelFactory, dataSource);
    }

    @NotNull
    @Override
    public StreamDataSource getSource() {
      return (StreamDataSource) super.getSource();
    }

    @Override
    public Map<String, String> getGenerationHashes() {
      Map<String, String> generationHashes = ModelDigestHelper.getInstance().getGenerationHashes(getSource());
      if (generationHashes != null) {
        return generationHashes;
      }

      return DefaultModelPersistence.getDigestMap(getSource());
    }

    @NotNull
    @Override
    public SModelHeader readHeader() throws ModelReadException {
      return ModelPersistence.loadDescriptor(getSource());
    }

    @NotNull
    @Override
    public ModelLoadResult readModel(@NotNull SModelHeader header, ModelLoadingState state) throws ModelReadException {
      return ModelPersistence.readModel(header, getSource(), state);
    }

    @Override
    public boolean doesSaveUpgradePersistence(@NotNull SModelHeader header) {
      //not sure !=-1 is really needed, just left to be ensured about compatibility
      return header.getPersistenceVersion() != ModelPersistence.LAST_VERSION && header.getPersistenceVersion() != -1;
    }

    @Override
    public void saveModel(@NotNull SModelHeader header, SModelData modelData) throws IOException {
      ModelPersistence.saveModel((jetbrains.mps.smodel.SModel) modelData, getSource(), header.getPersistenceVersion());
    }
  }
}
