/*
 * Copyright 2003-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 jetbrains.mps.smodel.persistence.def;

import jetbrains.mps.extapi.model.GeneratableSModel;
import jetbrains.mps.extapi.model.SModelData;
import jetbrains.mps.generator.ModelDigestUtil;
import jetbrains.mps.persistence.IndexAwareModelFactory.Callback;
import jetbrains.mps.persistence.xml.XMLPersistence;
import jetbrains.mps.persistence.xml.XMLPersistence.Indexer;
import jetbrains.mps.smodel.DefaultSModel;
import jetbrains.mps.smodel.SModel;
import jetbrains.mps.smodel.SModelHeader;
import jetbrains.mps.smodel.loading.ModelLoadResult;
import jetbrains.mps.smodel.loading.ModelLoadingState;
import jetbrains.mps.smodel.persistence.def.v9.ModelPersistence9;
import jetbrains.mps.smodel.persistence.lines.LineContent;
import jetbrains.mps.util.FileUtil;
import jetbrains.mps.util.JDOMUtil;
import jetbrains.mps.util.StringUtil;
import jetbrains.mps.util.annotation.ToRemove;
import jetbrains.mps.util.xml.BreakParseSAXException;
import jetbrains.mps.util.xml.XMLSAXHandler;
import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.jdom.Document;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.mps.openapi.model.SModelReference;
import org.jetbrains.mps.openapi.persistence.PersistenceFacade;
import org.jetbrains.mps.openapi.persistence.StreamDataSource;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * ModelPersistence handles all XML persistence versions supported by current MPS installation.
 * The range of supported versions is [FIRST_SUPPORTED_VERSION, LAST_VERSION].
 * MPS must be able to read any of these persistence versions and write the last one.
 * <p/>
 * The "previous" persistence version being writable is not a must, but it is better
 * to support it to have less moments when we accidentally need to convert
 * between persistence versions. E.g. to be able to fix model before migration from previous
 * version and save it in old persistence, or to merge two non-migrated branches
 * without converting persistence.
 * <p/>
 * It is supposed that only one or two persistence versions are supported:
 * the last persistence used by previous MPS version (to read and
 * migrate project created in the previous version, and, sometimes,
 * a new persistence introduced in current version.
 * NOTE this is not mandatory, we can support more than two versions.
 * <p/>
 * We can't support full functionality on all created persistences as the change
 * in persistence is actually made because of change in SModel. So, we can't
 * actual SModel to a very old persistence or even read all the information
 * from old persistence into a new SModel. The good thing about that is that we
 * can "partially" support very old persistence versions where we might need such a support.
 * See VCSPersistenceSupport for an example.
 */
public class ModelPersistence {
  private static final Logger LOG = LogManager.getLogger(ModelPersistence.class);

  public static final String MODEL = "model";
  public static final String REF = "ref";

  public static final String PERSISTENCE = "persistence";
  public static final String PERSISTENCE_VERSION = "version";

  public static final int FIRST_SUPPORTED_VERSION = 9;
  public static final int LAST_VERSION = 9;

  private static final int HEADER_READ_LIMIT = 1 << 16; // allow for huge headers

  public static boolean isSupported(int version) {
    return version >= FIRST_SUPPORTED_VERSION && version <= LAST_VERSION;
  }

  @Nullable
  public static IModelPersistence getPersistence(int version) {
    if (version == 9) {
      return new ModelPersistence9();
    }

    assert !isSupported(version) : "inconsistent ModelPersistence.isSupported and .getPersistence. Version=" + version;
    // callers generally handle null return value, no reason to frighten user away with a throwable in the log. Is there need for the message at all?
    LOG.debug("Unknown persistence version requested: " + version, new Throwable());
    return null;
  }

  @NotNull
  public static SModelHeader loadDescriptor(InputSource source) throws ModelReadException {
    try {
      SModelHeader result = new SModelHeader();
      parseAndHandleExceptions(source, new HeaderOnlyHandler(result));
      return result;
    } catch (Exception ex) {
      Throwable th = ex.getCause() == null ? ex : ex.getCause();
      throw new ModelReadException(String.format("Failed to read model header: %s", th.getMessage()), th);
    }
  }

  @NotNull
  public static SModelHeader loadDescriptor(StreamDataSource source) throws ModelReadException {
    InputStream in = null;
    try {
      in = source.openInputStream();
      final SModelHeader result = new SModelHeader();
      parseAndHandleExceptions(new InputSource(new InputStreamReader(in, FileUtil.DEFAULT_CHARSET)), new HeaderOnlyHandler(result));
      return result;
    } catch (Exception e) {
      Throwable th = e.getCause() == null ? e : e.getCause();
      throw new ModelReadException(String.format("Couldn't read descriptor from %s: %s", source.getLocation(), th.getMessage()), th);
    } finally {
      FileUtil.closeFileSafe(in);
    }
  }

  private static ModelLoadResult readModel(@NotNull SModelHeader header, @NotNull InputSource source, ModelLoadingState state) throws ModelReadException {
    int ver = header.getPersistenceVersion();
    if (ver < 0) {
      throw new ModelReadException("Couldn't read model because of unknown persistence version", null);
    }

    IModelPersistence mp;
    if (!isSupported(ver) || (mp = getPersistence(ver)) == null) {
      String m = "Can not find appropriate persistence version for model %s (requested:%d)\n Use newer version of JetBrains MPS to load this model.";
      throw new PersistenceVersionNotFoundException(String.format(m, header.getModelReference(), ver), header.getModelReference());
    }

    XMLSAXHandler<ModelLoadResult> handler = mp.getModelReaderHandler(state, header);
    if (handler == null) {
      throw new ModelReadException("Can not read model header", null, header.getModelReference());
    }
    try {
      parseAndHandleExceptions(source, handler);
      // in case persistence version could change during IModelPersistence activities, might need to update header:
      // header.setPersistenceVersion(mp.getVersion());
      return handler.getResult();
    } catch (Exception ex) {
      Throwable th = ex.getCause() == null ? ex : ex.getCause();
      final String causeText = th.getMessage() == null ? th.getClass().getSimpleName() : th.getMessage();
      throw new ModelReadException(String.format("Failed to load model: %s", causeText), th, header);
    }
  }

  @NotNull
  public static ModelLoadResult readModel(@NotNull SModelHeader header, @NotNull StreamDataSource dataSource, ModelLoadingState state) throws
      ModelReadException {
    InputStream in = null;
    try {
      in = dataSource.openInputStream();
      InputSource source = new InputSource(new InputStreamReader(in, FileUtil.DEFAULT_CHARSET));
      return readModel(header, source, state);
    } catch (IOException e) {
      throw new ModelReadException("Couldn't read model: " + e.getMessage(), e, header);
    } finally {
      FileUtil.closeFileSafe(in);
    }
  }

  @Nullable
  public static List<LineContent> getLineToContentMap(String content) throws ModelReadException {
    try {
      SModelHeader header = new SModelHeader();
      parseAndHandleExceptions(new InputSource(new StringReader(content)), new HeaderOnlyHandler(header));
      IModelPersistence mp = getPersistence(header.getPersistenceVersion());

      if (mp == null) {
        return null;
      }

      XMLSAXHandler<List<LineContent>> handler = mp.getLineToContentMapReaderHandler();
      if (handler == null) {
        return null;
      }

      parseAndHandleExceptions(new InputSource(new StringReader(content)), handler);
      return handler.getResult();
    } catch (Exception ex) {
      Throwable th = ex.getCause() == null ? ex : ex.getCause();
      throw new ModelReadException(String.format("Failed to load line to content map: %s", th.getMessage()), th);
    }
  }

  /*
   * FIXME why on earth we pass SModelData here, not openapi.SModel?
   * FIXME why does this method do silent update? Would be better to update explicitly, and fail from the method if can't save with specified version
   *  returns upgraded model, or null if the model doesn't require update
   */
  public static DefaultSModel saveModel(@NotNull SModel model, @NotNull StreamDataSource source, int persistenceVersion) throws IOException {
    LOG.debug("Saving model " + model.getReference() + " to " + source.getLocation());

    if (source.isReadOnly()) {
      throw new IOException("`" + source.getLocation() + "' is read-only");
    }

    // upgrade?
    int oldVersion = persistenceVersion;
    if (model instanceof DefaultSModel) {
      DefaultSModel dsm = (DefaultSModel) model;
      SModelHeader modelHeader = dsm.getSModelHeader();
      oldVersion = modelHeader.getPersistenceVersion();
      if (oldVersion != persistenceVersion) {
        modelHeader.setPersistenceVersion(persistenceVersion);
      }
    }

    // save model
    Document document = modelToXml(model, persistenceVersion);
    JDOMUtil.writeDocument(document, source);

    if (oldVersion != persistenceVersion) {
      LOG.info("persistence upgraded: " + oldVersion + "->" + persistenceVersion + " " + model.getReference());
      return (DefaultSModel) model;
    }
    return null;
  }

  /**
   * Serialize model into xml, conformant to actual model's persistence version, if any, or current persistence version otherwise.
   * The method doesn't update persistence version of the model (as it used to do)
   */
  @NotNull
  public static Document saveModel(@NotNull SModel sourceModel) {
    int persistenceVersion = -1;
    if (sourceModel instanceof DefaultSModel) {
      persistenceVersion = ((DefaultSModel) sourceModel).getSModelHeader().getPersistenceVersion();
    }
    if (persistenceVersion == -1 || !isSupported(persistenceVersion) || getPersistence(persistenceVersion) == null) {
      persistenceVersion = ModelPersistence.LAST_VERSION;
    }
    return modelToXml(sourceModel, persistenceVersion);
  }

  /**
   * Serialize model to xml in conformance with given persistence version.
   *
   * @throws java.lang.IllegalArgumentException if persistenceVersion is invalid (use {@link #LAST_VERSION} if uncertain
   */
  private static Document modelToXml(@NotNull SModel model, int persistenceVersion) {
    IModelPersistence modelPersistence = getPersistence(persistenceVersion);
    if (modelPersistence == null) {
      throw new IllegalArgumentException(String.format("Unknown persistence version %d", persistenceVersion));
    }
    IModelWriter writer = modelPersistence.getModelWriter(model instanceof DefaultSModel ? ((DefaultSModel) model).getSModelHeader() : null);
    if (writer == null) {
      throw new IllegalArgumentException(String.format("Persistence has no writer. Version %d", persistenceVersion));
    }
    return writer.saveModel(model);
  }

  public static Map<String, String> calculateHashes(String content) throws ModelReadException {
    SModelHeader header = loadDescriptor(new InputSource(new StringReader(content)));
    IModelPersistence mp = getPersistence(header.getPersistenceVersion());
    Map<String, String> result;
    if (mp != null) {
      IHashProvider hashProvider = mp.getHashProvider();
      result = hashProvider.getRootHashes(content);
      result.put(GeneratableSModel.FILE, hashProvider.getHash(content));
    } else {
      result = new HashMap<String, String>();
      result.put(GeneratableSModel.FILE, ModelDigestUtil.hashText(content));
    }
    return result;
  }

  @NotNull
  public static DefaultSModel readModel(@NotNull final StreamDataSource source, boolean interfaceOnly) throws ModelReadException {
    SModelHeader header = loadDescriptor(source);
    ModelLoadingState state = interfaceOnly ? ModelLoadingState.INTERFACE_LOADED : ModelLoadingState.FULLY_LOADED;
    return (DefaultSModel) readModel(header, source, state).getModel();
  }

  @NotNull
  public static DefaultSModel readModel(@NotNull final String content, boolean interfaceOnly) throws ModelReadException {
    SModelHeader header = loadDescriptor(new InputSource(new StringReader(content)));
    ModelLoadingState state = interfaceOnly ? ModelLoadingState.INTERFACE_LOADED : ModelLoadingState.FULLY_LOADED;
    return (DefaultSModel) readModel(header, new InputSource(new StringReader(content)), state).getModel();
  }

  @NotNull
  public static String modelToString(@NotNull final SModel model) {
    return JDOMUtil.asString(saveModel(model));
  }

  // propagates exceptions that had happened during read, except for special case when we deliberately stop parsing process
  // wrap certain errors as exceptions to facilitate broken model instead of broken MPS
  static void parseAndHandleExceptions(InputSource source, DefaultHandler handler) throws Exception {
    try {
      JDOMUtil.createSAXParser().parse(source, handler);
    } catch (BreakParseSAXException e) {
      /* used to break SAX parsing flow */
    } catch (AssertionError er) {
      // just in case something goes wrong deep inside our persistence implementation, let MPS go on with broken model
      throw new Exception(er);
    }
  }



  /**
   * @deprecated use {@link #index(InputStream, Callback)} instead
   */
  @Deprecated
  @ToRemove(version = 0)
  public static void index(byte[] data, Callback newConsumer) throws IOException {
    index(new ByteArrayInputStream(data), newConsumer);
  }

  private static void assertMarkSupported(InputStream stream) {
    // Both BufferedInputStream and ByteArrayInputStream do support marks, latter without limit.
    assert stream.markSupported() : "XML model persistence reads the stream twice (to parse header and to figure out persistence version)";
  }

  public static void index(InputStream data, Callback newConsumer) throws IOException {
    assertMarkSupported(data);
    try {
      SModelHeader header = new SModelHeader();
      data.mark(HEADER_READ_LIMIT);
      InputSource source = new InputSource(new InputStreamReader(data, FileUtil.DEFAULT_CHARSET));
      parseAndHandleExceptions(source, new HeaderOnlyHandler(header));
      IModelPersistence mp = getPersistence(header.getPersistenceVersion());
      if (!(mp instanceof XMLPersistence)) {
        LOG.error("Can't index old persistence. Please update persistence of old models.\n" +
            "Persistence version: " + header.getPersistenceVersion() + "\n" +
            "Model: " + header.getModelReference().getModelName());
        return;
      }

      data.reset();
      Indexer indexSupport = ((XMLPersistence) mp).getIndexSupport(newConsumer);
      indexSupport.index(new InputStreamReader(data, FileUtil.DEFAULT_CHARSET));
    } catch (IOException ex) {
      throw ex;
    } catch (Exception ex) {
      Throwable th = ex.getCause() == null ? ex : ex.getCause();
      throw new IOException(th);
    }
  }

  public static SModelData getModelData(@NotNull InputStream input) throws IOException {
    assertMarkSupported(input);
    try {
      input.mark(HEADER_READ_LIMIT);
      SModelHeader header = loadDescriptor(new InputSource(new InputStreamReader(input, FileUtil.DEFAULT_CHARSET)));
      input.reset();
      ModelLoadResult result = readModel(header, new InputSource(new InputStreamReader(input, FileUtil.DEFAULT_CHARSET)), ModelLoadingState.FULLY_LOADED);
      return result.getModel();
    } catch (ModelReadException e) {
      throw new IOException(e);
    }
  }

  private static class HeaderOnlyHandler extends DefaultHandler {
    private final SModelHeader myResult;

    public HeaderOnlyHandler(SModelHeader result) {
      myResult = result;
    }

    @Override
    public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
      //todo this must be simplified as soon as all models are re-saved in last v9 persistence or later
      if (MODEL.equals(qName)) {
        for (int idx = 0; idx < attributes.getLength(); idx++) {
          String name = attributes.getQName(idx);
          String value = attributes.getValue(idx);
          if ("modelUID".equals(name) || ModelPersistence9.REF.equals(name)) {
            final SModelReference mr = value == null ? null : PersistenceFacade.getInstance().createModelReference(value);
            myResult.setModelReference(mr);
          } else if (SModelHeader.DO_NOT_GENERATE.equals(name)) {
            myResult.setDoNotGenerate(Boolean.parseBoolean(value));
          } else {
            myResult.setOptionalProperty(name, StringUtil.unescapeXml(value));
          }
        }
      } else if (PERSISTENCE.equals(qName)) {
        String s = attributes.getValue(PERSISTENCE_VERSION);
        if (s != null) {
          try {
            myResult.setPersistenceVersion(Integer.parseInt(s));
          } catch (NumberFormatException ignored) {
          }
        }
      } else {
        throw new BreakParseSAXException();
      }
    }

    @Override
    public void endElement(String uri, String localName, String qName) throws SAXException {
      throw new BreakParseSAXException();
    }
  }
}
