/*
 * 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.ratis.server.storage;

import org.apache.ratis.util.AtomicFileOutputStream;
import org.apache.ratis.util.FileUtils;
import org.apache.ratis.util.SizeInBytes;

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.lang.management.ManagementFactory;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;

import static java.nio.file.Files.newDirectoryStream;

class RaftStorageDirectoryImpl implements RaftStorageDirectory {

  private static final String IN_USE_LOCK_NAME = "in_use.lock";
  private static final String META_FILE_NAME = "raft-meta";
  private static final String CONF_EXTENSION = ".conf";
  private static final String JVM_NAME = ManagementFactory.getRuntimeMXBean().getName();

  enum StorageState {
    UNINITIALIZED,
    NON_EXISTENT,
    NOT_FORMATTED,
    NO_SPACE,
    NORMAL
  }

  private final File root; // root directory
  private FileLock lock;   // storage lock
  private final SizeInBytes freeSpaceMin;

  /**
   * Constructor
   * @param dir directory corresponding to the storage
   */
  RaftStorageDirectoryImpl(File dir, SizeInBytes freeSpaceMin) {
    this.root = dir;
    this.lock = null;
    this.freeSpaceMin = freeSpaceMin;
  }

  @Override
  public File getRoot() {
    return root;
  }

  /**
   * Clear and re-create storage directory.
   * <p>
   * Removes contents of the current directory and creates an empty directory.
   *
   * This does not fully format storage directory.
   * It cannot write the version file since it should be written last after
   * all other storage type dependent files are written.
   * Derived storage is responsible for setting specific storage values and
   * writing the version file to disk.
   */
  void clearDirectory() throws IOException {
    clearDirectory(getCurrentDir());
    clearDirectory(getStateMachineDir());
  }

  private static void clearDirectory(File dir) throws IOException {
    if (dir.exists()) {
      LOG.info("{} already exists.  Deleting it ...", dir);
      FileUtils.deleteFully(dir);
    }
    FileUtils.createDirectories(dir);
  }


  File getMetaFile() {
    return new File(getCurrentDir(), META_FILE_NAME);
  }

  File getMetaTmpFile() {
    return AtomicFileOutputStream.getTemporaryFile(getMetaFile());
  }

  File getMetaConfFile() {
    return new File(getCurrentDir(), META_FILE_NAME + CONF_EXTENSION);
  }

  /**
   * Check to see if current/ directory is empty.
   */
  boolean isCurrentEmpty() throws IOException {
    File currentDir = getCurrentDir();
    if(!currentDir.exists()) {
      // if current/ does not exist, it's safe to format it.
      return true;
    }
    try(DirectoryStream<Path> dirStream =
            newDirectoryStream(currentDir.toPath())) {
      if (dirStream.iterator().hasNext()) {
        return false;
      }
    }
    return true;
  }

  /**
   * Check consistency of the storage directory.
   *
   * @return state {@link StorageState} of the storage directory
   */
  StorageState analyzeStorage(boolean toLock) throws IOException {
    Objects.requireNonNull(root, "root directory is null");

    String rootPath = root.getCanonicalPath();
    try { // check that storage exists
      if (!root.exists()) {
        LOG.info("The storage directory {} does not exist. Creating ...", rootPath);
        FileUtils.createDirectories(root);
      }
      // or is inaccessible
      if (!root.isDirectory()) {
        LOG.warn("{} is not a directory", rootPath);
        return StorageState.NON_EXISTENT;
      }
      if (!Files.isWritable(root.toPath())) {
        LOG.warn("The storage directory {} is not writable.", rootPath);
        return StorageState.NON_EXISTENT;
      }
    } catch(SecurityException ex) {
      LOG.warn("Cannot access storage directory " + rootPath, ex);
      return StorageState.NON_EXISTENT;
    }

    if (toLock) {
      this.lock(); // lock storage if it exists
    }

    // check enough space
    final long freeSpace = root.getFreeSpace();
    if (freeSpace < freeSpaceMin.getSize()) {
      LOG.warn("{} in directory {}: free space = {} < required = {}",
          StorageState.NO_SPACE, rootPath, freeSpace, freeSpaceMin);
      return StorageState.NO_SPACE;
    }

    // check whether current directory is valid
    if (isHealthy()) {
      return StorageState.NORMAL;
    } else {
      return StorageState.NOT_FORMATTED;
    }
  }

  @Override
  public boolean isHealthy() {
    return getMetaFile().exists();
  }

  /**
   * Lock storage to provide exclusive access.
   *
   * <p> Locking is not supported by all file systems.
   * E.g., NFS does not consistently support exclusive locks.
   *
   * <p> If locking is supported we guarantee exclusive access to the
   * storage directory. Otherwise, no guarantee is given.
   *
   * @throws IOException if locking fails
   */
  void lock() throws IOException {
    final File lockF = new File(root, IN_USE_LOCK_NAME);
    final FileLock newLock = FileUtils.attempt(() -> tryLock(lockF), () -> "tryLock " + lockF);
    if (newLock == null) {
      String msg = "Cannot lock storage " + this.root
          + ". The directory is already locked";
      LOG.info(msg);
      throw new IOException(msg);
    }
    // Don't overwrite lock until success - this way if we accidentally
    // call lock twice, the internal state won't be cleared by the second
    // (failed) lock attempt
    lock = newLock;
  }

  /**
   * Attempts to acquire an exclusive lock on the storage.
   *
   * @return A lock object representing the newly-acquired lock or
   * <code>null</code> if storage is already locked.
   * @throws IOException if locking fails.
   */
  @SuppressWarnings({"squid:S2095"}) // Suppress closeable  warning
  private FileLock tryLock(File lockF) throws IOException {
    boolean deletionHookAdded = false;
    if (!lockF.exists()) {
      lockF.deleteOnExit();
      deletionHookAdded = true;
    }
    RandomAccessFile file = new RandomAccessFile(lockF, "rws");
    FileLock res;
    try {
      res = file.getChannel().tryLock();
      if (null == res) {
        LOG.error("Unable to acquire file lock on path {}", lockF);
        throw new OverlappingFileLockException();
      }
      file.write(JVM_NAME.getBytes(StandardCharsets.UTF_8));
      LOG.info("Lock on {} acquired by nodename {}", lockF, JVM_NAME);
    } catch (OverlappingFileLockException oe) {
      // Cannot read from the locked file on Windows.
      LOG.error("It appears that another process "
          + "has already locked the storage directory: " + root, oe);
      file.close();
      throw new IOException("Failed to lock storage " + this.root + ". The directory is already locked", oe);
    } catch(IOException e) {
      LOG.error("Failed to acquire lock on " + lockF
          + ". If this storage directory is mounted via NFS, "
          + "ensure that the appropriate nfs lock services are running.", e);
      file.close();
      throw e;
    }
    if (!deletionHookAdded) {
      // If the file existed prior to our startup, we didn't
      // call deleteOnExit above. But since we successfully locked
      // the dir, we can take care of cleaning it up.
      lockF.deleteOnExit();
    }
    return res;
  }

  /**
   * Unlock storage.
   */
  void unlock() throws IOException {
    if (this.lock == null) {
      return;
    }
    this.lock.release();
    lock.channel().close();
    lock = null;
  }

  @Override
  public String toString() {
    return "Storage Directory " + this.root;
  }
}
