/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.solr.core.snapshots;

import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexCommit;
import org.apache.lucene.index.IndexDeletionPolicy;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.util.IOUtils;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SolrException.ErrorCode;
import org.apache.solr.core.DirectoryFactory;
import org.apache.solr.core.DirectoryFactory.DirContext;
import org.apache.solr.core.IndexDeletionPolicyWrapper;
import org.apache.solr.core.SolrCore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class is responsible to manage the persistent snapshots meta-data for the Solr indexes. The
 * persistent snapshots are implemented by relying on Lucene {@linkplain IndexDeletionPolicy}
 * abstraction to configure a specific {@linkplain IndexCommit} to be retained. The
 * {@linkplain IndexDeletionPolicyWrapper} in Solr uses this class to create/delete the Solr index
 * snapshots.
 */
public class SolrSnapshotMetaDataManager {
  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
  public static final String SNAPSHOT_METADATA_DIR = "snapshot_metadata";

  /**
   * A class defining the meta-data for a specific snapshot.
   */
  public static class SnapshotMetaData {
    private String name;
    private String indexDirPath;
    private long generationNumber;

    public SnapshotMetaData(String name, String indexDirPath, long generationNumber) {
      super();
      this.name = name;
      this.indexDirPath = indexDirPath;
      this.generationNumber = generationNumber;
    }

    public String getName() {
      return name;
    }

    public String getIndexDirPath() {
      return indexDirPath;
    }

    public long getGenerationNumber() {
      return generationNumber;
    }

    @Override
    public String toString() {
      StringBuilder builder = new StringBuilder();
      builder.append("SnapshotMetaData[name=");
      builder.append(name);
      builder.append(", indexDirPath=");
      builder.append(indexDirPath);
      builder.append(", generation=");
      builder.append(generationNumber);
      builder.append("]");
      return builder.toString();
    }
  }

  /** Prefix used for the save file. */
  public static final String SNAPSHOTS_PREFIX = "snapshots_";
  private static final int VERSION_START = 0;
  private static final int VERSION_CURRENT = VERSION_START;
  private static final String CODEC_NAME = "solr-snapshots";

  // The index writer which maintains the snapshots metadata
  private long nextWriteGen;

  private final Directory dir;

  /** Used to map snapshot name to snapshot meta-data. */
  protected final Map<String,SnapshotMetaData> nameToDetailsMapping = new LinkedHashMap<>();
  /** Used to figure out the *current* index data directory path */
  private final SolrCore solrCore;

  /**
   * A constructor.
   *
   * @param dir The directory where the snapshot meta-data should be stored. Enables updating
   *            the existing meta-data.
   * @throws IOException in case of errors.
   */
  public SolrSnapshotMetaDataManager(SolrCore solrCore, Directory dir) throws IOException {
    this(solrCore, dir, OpenMode.CREATE_OR_APPEND);
  }

  /**
   * A constructor.
   *
   * @param dir The directory where the snapshot meta-data is stored.
   * @param mode CREATE If previous meta-data should be erased.
   *             APPEND If previous meta-data should be read and updated.
   *             CREATE_OR_APPEND Creates a new meta-data structure if one does not exist
   *                              Updates the existing structure if one exists.
   * @throws IOException in case of errors.
   */
  public SolrSnapshotMetaDataManager(SolrCore solrCore, Directory dir, OpenMode mode) throws IOException {
    this.solrCore = solrCore;
    this.dir = dir;

    if (mode == OpenMode.CREATE) {
      deleteSnapshotMetadataFiles();
    }

    loadFromSnapshotMetadataFile();

    if (mode == OpenMode.APPEND && nextWriteGen == 0) {
      throw new IllegalStateException("no snapshots stored in this directory");
    }
  }

  /**
   * @return The snapshot meta-data directory
   */
  public Directory getSnapshotsDir() {
    return dir;
  }

  /**
   * This method creates a new snapshot meta-data entry.
   *
   * @param name The name of the snapshot.
   * @param indexDirPath The directory path where the index files are stored.
   * @param gen The generation number for the {@linkplain IndexCommit} being snapshotted.
   * @throws IOException in case of I/O errors.
   */
  public synchronized void snapshot(String name, String indexDirPath, long gen) throws IOException {
    Objects.requireNonNull(name);

    log.info("Creating the snapshot named {} for core {} associated with index commit with generation {} in directory {}"
        , name, solrCore.getName(), gen, indexDirPath);

    if(nameToDetailsMapping.containsKey(name)) {
      throw new SolrException(ErrorCode.BAD_REQUEST, "A snapshot with name " + name + " already exists");
    }

    SnapshotMetaData d = new SnapshotMetaData(name, indexDirPath, gen);
    nameToDetailsMapping.put(name, d);

    boolean success = false;
    try {
      persist();
      success = true;
    } finally {
      if (!success) {
        try {
          release(name);
        } catch (Exception e) {
          // Suppress so we keep throwing original exception
        }
      }
    }
  }

  /**
   * This method deletes a previously created snapshot (if any).
   *
   * @param name The name of the snapshot to be deleted.
   * @return The snapshot meta-data if the snapshot with the snapshot name exists.
   * @throws IOException in case of I/O error
   */
  public synchronized Optional<SnapshotMetaData> release(String name) throws IOException {
    log.info("Deleting the snapshot named {} for core {}", name, solrCore.getName());
    SnapshotMetaData result = nameToDetailsMapping.remove(Objects.requireNonNull(name));
    if(result != null) {
      boolean success = false;
      try {
        persist();
        success = true;
      } finally {
        if (!success) {
          nameToDetailsMapping.put(name, result);
        }
      }
    }
    return Optional.ofNullable(result);
  }

  /**
   * This method returns if snapshot is created for the specified generation number in
   * the *current* index directory.
   *
   * @param genNumber The generation number for the {@linkplain IndexCommit} to be checked.
   * @return true if the snapshot is created.
   *         false otherwise.
   */
  public synchronized boolean isSnapshotted(long genNumber) {
    return !nameToDetailsMapping.isEmpty() && isSnapshotted(solrCore.getIndexDir(), genNumber);
  }

  /**
   * This method returns if snapshot is created for the specified generation number in
   * the specified index directory.
   *
   * @param genNumber The generation number for the {@linkplain IndexCommit} to be checked.
   * @return true if the snapshot is created.
   *         false otherwise.
   */
  public synchronized boolean isSnapshotted(String indexDirPath, long genNumber) {
    return !nameToDetailsMapping.isEmpty()
        && nameToDetailsMapping.values().stream()
           .anyMatch(entry -> entry.getIndexDirPath().equals(indexDirPath) && entry.getGenerationNumber() == genNumber);
  }

  /**
   * This method returns the snapshot meta-data for the specified name (if it exists).
   *
   * @param name The name of the snapshot
   * @return The snapshot meta-data if exists.
   */
  public synchronized Optional<SnapshotMetaData> getSnapshotMetaData(String name) {
    return Optional.ofNullable(nameToDetailsMapping.get(name));
  }

  /**
   * @return A list of snapshots created so far.
   */
  public synchronized List<String> listSnapshots() {
    // We create a copy for thread safety.
    return new ArrayList<>(nameToDetailsMapping.keySet());
  }

  /**
   * This method returns a list of snapshots created in a specified index directory.
   *
   * @param indexDirPath The index directory path.
   * @return a list snapshots stored in the specified directory.
   */
  public synchronized Collection<SnapshotMetaData> listSnapshotsInIndexDir(String indexDirPath) {
    return nameToDetailsMapping.values().stream()
        .filter(entry -> indexDirPath.equals(entry.getIndexDirPath()))
        .collect(Collectors.toList());
  }

  /**
   * This method returns the {@linkplain IndexCommit} associated with the specified
   * <code>commitName</code>. A snapshot with specified <code>commitName</code> must
   * be created before invoking this method.
   *
   * @param commitName The name of persisted commit
   * @return the {@linkplain IndexCommit}
   * @throws IOException in case of I/O error.
   */
  public Optional<IndexCommit> getIndexCommitByName(String commitName) throws IOException {
    Optional<IndexCommit> result = Optional.empty();
    Optional<SnapshotMetaData> metaData = getSnapshotMetaData(commitName);
    if (metaData.isPresent()) {
      String indexDirPath = metaData.get().getIndexDirPath();
      long gen = metaData.get().getGenerationNumber();

      Directory d = solrCore.getDirectoryFactory().get(indexDirPath, DirContext.DEFAULT, DirectoryFactory.LOCK_TYPE_NONE);
      try {
        result = DirectoryReader.listCommits(d)
                                .stream()
                                .filter(ic -> ic.getGeneration() == gen)
                                .findAny();

        if (!result.isPresent()) {
          log.warn("Unable to find commit with generation {} in the directory {}", gen, indexDirPath);
        }

      } finally {
        solrCore.getDirectoryFactory().release(d);
      }
    } else {
      log.warn("Commit with name {} is not persisted for core {}", commitName, solrCore.getName());
    }

    return result;
  }

  private synchronized void persist() throws IOException {
    String fileName = SNAPSHOTS_PREFIX + nextWriteGen;
    IndexOutput out = dir.createOutput(fileName, IOContext.DEFAULT);
    boolean success = false;
    try {
      CodecUtil.writeHeader(out, CODEC_NAME, VERSION_CURRENT);
      out.writeVInt(nameToDetailsMapping.size());
      for(Entry<String,SnapshotMetaData> ent : nameToDetailsMapping.entrySet()) {
        out.writeString(ent.getKey());
        out.writeString(ent.getValue().getIndexDirPath());
        out.writeVLong(ent.getValue().getGenerationNumber());
      }
      success = true;
    } finally {
      if (!success) {
        IOUtils.closeWhileHandlingException(out);
        IOUtils.deleteFilesIgnoringExceptions(dir, fileName);
      } else {
        IOUtils.close(out);
      }
    }

    dir.sync(Collections.singletonList(fileName));

    if (nextWriteGen > 0) {
      String lastSaveFile = SNAPSHOTS_PREFIX + (nextWriteGen-1);
      // exception OK: likely it didn't exist
      IOUtils.deleteFilesIgnoringExceptions(dir, lastSaveFile);
    }

    nextWriteGen++;
  }

  private synchronized void deleteSnapshotMetadataFiles() throws IOException {
    for(String file : dir.listAll()) {
      if (file.startsWith(SNAPSHOTS_PREFIX)) {
        dir.deleteFile(file);
      }
    }
  }

  /**
   * Reads the snapshot meta-data information from the given {@link Directory}.
   */
  private synchronized void loadFromSnapshotMetadataFile() throws IOException {
    log.debug("Loading from snapshot metadata file...");
    long genLoaded = -1;
    IOException ioe = null;
    List<String> snapshotFiles = new ArrayList<>();
    for(String file : dir.listAll()) {
      if (file.startsWith(SNAPSHOTS_PREFIX)) {
        long gen = Long.parseLong(file.substring(SNAPSHOTS_PREFIX.length()));
        if (genLoaded == -1 || gen > genLoaded) {
          snapshotFiles.add(file);
          Map<String, SnapshotMetaData> snapshotMetaDataMapping = new HashMap<>();
          IndexInput in = dir.openInput(file, IOContext.DEFAULT);
          try {
            CodecUtil.checkHeader(in, CODEC_NAME, VERSION_START, VERSION_START);
            int count = in.readVInt();
            for(int i=0;i<count;i++) {
              String name = in.readString();
              String indexDirPath = in.readString();
              long commitGen = in.readVLong();
              snapshotMetaDataMapping.put(name, new SnapshotMetaData(name, indexDirPath, commitGen));
            }
          } catch (IOException ioe2) {
            // Save first exception & throw in the end
            if (ioe == null) {
              ioe = ioe2;
            }
          } finally {
            in.close();
          }

          genLoaded = gen;
          nameToDetailsMapping.clear();
          nameToDetailsMapping.putAll(snapshotMetaDataMapping);
        }
      }
    }

    if (genLoaded == -1) {
      // Nothing was loaded...
      if (ioe != null) {
        // ... not for lack of trying:
        throw ioe;
      }
    } else {
      if (snapshotFiles.size() > 1) {
        // Remove any broken / old snapshot files:
        String curFileName = SNAPSHOTS_PREFIX + genLoaded;
        for(String file : snapshotFiles) {
          if (!curFileName.equals(file)) {
            IOUtils.deleteFilesIgnoringExceptions(dir, file);
          }
        }
      }
      nextWriteGen = 1+genLoaded;
    }
  }
}
