/**
 * 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
 * <p>
 * http://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * 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.hadoop.hdds.utils;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.hadoop.hdds.StringUtils;
import org.apache.hadoop.hdds.conf.OzoneConfiguration;
import org.apache.hadoop.hdds.utils.MetadataKeyFilters.KeyPrefixFilter;
import org.apache.hadoop.hdds.utils.MetadataKeyFilters.MetadataKeyFilter;
import org.apache.hadoop.ozone.OzoneConfigKeys;
import org.apache.hadoop.test.GenericTestUtils;

import com.google.common.collect.Lists;
import static java.nio.charset.StandardCharsets.UTF_8;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.tuple.ImmutablePair;
import static org.apache.hadoop.test.PlatformAssumptions.assumeNotWindows;
import org.junit.After;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import static org.junit.runners.Parameterized.Parameters;
import org.slf4j.event.Level;

/**
 * Test class for ozone metadata store.
 */
@RunWith(Parameterized.class)
public class TestMetadataStore {

  private final static int MAX_GETRANGE_LENGTH = 100;
  private final String storeImpl;
  @Rule
  public ExpectedException expectedException = ExpectedException.none();
  private MetadataStore store;
  private File testDir;

  public TestMetadataStore(String metadataImpl) {
    this.storeImpl = metadataImpl;
  }

  @Parameters
  public static Collection<Object[]> data() {
    return Arrays.asList(new Object[][] {
        {OzoneConfigKeys.OZONE_METADATA_STORE_IMPL_LEVELDB},
        {OzoneConfigKeys.OZONE_METADATA_STORE_IMPL_ROCKSDB}
    });
  }

  @Before
  public void init() throws IOException {
    if (OzoneConfigKeys.OZONE_METADATA_STORE_IMPL_ROCKSDB.equals(storeImpl)) {
      // The initialization of RocksDB fails on Windows
      assumeNotWindows();
    }

    testDir = GenericTestUtils.getTestDir(getClass().getSimpleName()
        + "-" + storeImpl.toLowerCase());

    OzoneConfiguration conf = new OzoneConfiguration();
    conf.set(OzoneConfigKeys.OZONE_METADATA_STORE_IMPL, storeImpl);

    store = MetadataStoreBuilder.newBuilder()
        .setConf(conf)
        .setCreateIfMissing(true)
        .setDbFile(testDir)
        .build();

    // Add 20 entries.
    // {a0 : a-value0} to {a9 : a-value9}
    // {b0 : b-value0} to {b9 : b-value9}
    for (int i = 0; i < 10; i++) {
      store.put(getBytes("a" + i), getBytes("a-value" + i));
      store.put(getBytes("b" + i), getBytes("b-value" + i));
    }
  }

  @Test
  public void testIterator() throws Exception {
    OzoneConfiguration conf = new OzoneConfiguration();
    conf.set(OzoneConfigKeys.OZONE_METADATA_STORE_IMPL, storeImpl);
    File dbDir = GenericTestUtils.getRandomizedTestDir();
    MetadataStore dbStore = MetadataStoreBuilder.newBuilder()
        .setConf(conf)
        .setCreateIfMissing(true)
        .setDbFile(dbDir)
        .build();

    //As database is empty, check whether iterator is working as expected or
    // not.
    MetaStoreIterator<MetadataStore.KeyValue> metaStoreIterator =
        dbStore.iterator();
    assertFalse(metaStoreIterator.hasNext());
    try {
      metaStoreIterator.next();
      fail("testIterator failed");
    } catch (NoSuchElementException ex) {
      GenericTestUtils.assertExceptionContains("Store has no more elements",
          ex);
    }

    for (int i = 0; i < 10; i++) {
      store.put(getBytes("a" + i), getBytes("a-value" + i));
    }

    metaStoreIterator = dbStore.iterator();

    int i = 0;
    while (metaStoreIterator.hasNext()) {
      MetadataStore.KeyValue val = metaStoreIterator.next();
      assertEquals("a" + i, getString(val.getKey()));
      assertEquals("a-value" + i, getString(val.getValue()));
      i++;
    }

    // As we have iterated all the keys in database, hasNext should return
    // false and next() should throw NoSuchElement exception.

    assertFalse(metaStoreIterator.hasNext());
    try {
      metaStoreIterator.next();
      fail("testIterator failed");
    } catch (NoSuchElementException ex) {
      GenericTestUtils.assertExceptionContains("Store has no more elements",
          ex);
    }
    dbStore.close();
    dbStore.destroy();
    FileUtils.deleteDirectory(dbDir);

  }

  @Test
  public void testMetaStoreConfigDifferentFromType() throws IOException {

    OzoneConfiguration conf = new OzoneConfiguration();
    conf.set(OzoneConfigKeys.OZONE_METADATA_STORE_IMPL, storeImpl);
    String dbType;
    GenericTestUtils.setLogLevel(MetadataStoreBuilder.LOG, Level.DEBUG);
    GenericTestUtils.LogCapturer logCapturer =
        GenericTestUtils.LogCapturer.captureLogs(MetadataStoreBuilder.LOG);
    if (storeImpl.equals(OzoneConfigKeys.OZONE_METADATA_STORE_IMPL_LEVELDB)) {
      dbType = "RocksDB";
    } else {
      dbType = "LevelDB";
    }

    File dbDir = GenericTestUtils.getTestDir(getClass().getSimpleName()
        + "-" + dbType.toLowerCase() + "-test");
    MetadataStore dbStore = MetadataStoreBuilder.newBuilder().setConf(conf)
        .setCreateIfMissing(true).setDbFile(dbDir).setDBType(dbType).build();
    assertTrue(logCapturer.getOutput().contains("Using dbType " + dbType + "" +
        " for metastore"));
    dbStore.close();
    dbStore.destroy();
    FileUtils.deleteDirectory(dbDir);

  }

  @Test
  public void testdbTypeNotSet() throws IOException {

    OzoneConfiguration conf = new OzoneConfiguration();
    conf.set(OzoneConfigKeys.OZONE_METADATA_STORE_IMPL, storeImpl);
    GenericTestUtils.setLogLevel(MetadataStoreBuilder.LOG, Level.DEBUG);
    GenericTestUtils.LogCapturer logCapturer =
        GenericTestUtils.LogCapturer.captureLogs(MetadataStoreBuilder.LOG);

    File dbDir = GenericTestUtils.getTestDir(getClass().getSimpleName()
        + "-" + storeImpl.toLowerCase() + "-test");
    MetadataStore dbStore = MetadataStoreBuilder.newBuilder().setConf(conf)
        .setCreateIfMissing(true).setDbFile(dbDir).build();
    assertTrue(logCapturer.getOutput().contains("dbType is null, using dbType" +
        " " + storeImpl));
    dbStore.close();
    dbStore.destroy();
    FileUtils.deleteDirectory(dbDir);

  }

  @After
  public void cleanup() throws IOException {
    if (store != null) {
      store.close();
      store.destroy();
    }
    if (testDir != null) {
      FileUtils.deleteDirectory(testDir);
    }
  }

  private byte[] getBytes(String str) {
    return str == null ? null :
        StringUtils.string2Bytes(str);
  }

  private String getString(byte[] bytes) {
    return bytes == null ? null :
        StringUtils.bytes2String(bytes);
  }

  @Test
  public void testGetDelete() throws IOException {
    for (int i = 0; i < 10; i++) {
      byte[] va = store.get(getBytes("a" + i));
      assertEquals("a-value" + i, getString(va));

      byte[] vb = store.get(getBytes("b" + i));
      assertEquals("b-value" + i, getString(vb));
    }

    String keyToDel = "del-" + UUID.randomUUID().toString();
    store.put(getBytes(keyToDel), getBytes(keyToDel));
    assertEquals(keyToDel, getString(store.get(getBytes(keyToDel))));
    store.delete(getBytes(keyToDel));
    assertEquals(null, store.get(getBytes(keyToDel)));
  }

  @Test
  public void testPeekFrom() throws IOException {
    // Test peek from an element that has prev as well as next
    testPeek("a3", "a2", "a4");

    // Test peek from an element that only has prev
    testPeek("b9", "b8", null);

    // Test peek from an element that only has next
    testPeek("a0", null, "a1");
  }

  private String getExpectedValue(String key) {
    if (key == null) {
      return null;
    }
    char[] arr = key.toCharArray();
    return new StringBuilder().append(arr[0]).append("-value")
        .append(arr[arr.length - 1]).toString();
  }

  private void testPeek(String peekKey, String prevKey, String nextKey)
      throws IOException {
    // Look for current
    String k = null;
    String v = null;
    ImmutablePair<byte[], byte[]> current =
        store.peekAround(0, getBytes(peekKey));
    if (current != null) {
      k = getString(current.getKey());
      v = getString(current.getValue());
    }
    assertEquals(peekKey, k);
    assertEquals(v, getExpectedValue(peekKey));

    // Look for prev
    k = null;
    v = null;
    ImmutablePair<byte[], byte[]> prev =
        store.peekAround(-1, getBytes(peekKey));
    if (prev != null) {
      k = getString(prev.getKey());
      v = getString(prev.getValue());
    }
    assertEquals(prevKey, k);
    assertEquals(v, getExpectedValue(prevKey));

    // Look for next
    k = null;
    v = null;
    ImmutablePair<byte[], byte[]> next =
        store.peekAround(1, getBytes(peekKey));
    if (next != null) {
      k = getString(next.getKey());
      v = getString(next.getValue());
    }
    assertEquals(nextKey, k);
    assertEquals(v, getExpectedValue(nextKey));
  }

  @Test
  public void testIterateKeys() throws IOException {
    // iterate keys from b0
    ArrayList<String> result = Lists.newArrayList();
    store.iterate(getBytes("b0"), (k, v) -> {
      // b-value{i}
      String value = getString(v);
      char num = value.charAt(value.length() - 1);
      // each value adds 1
      int i = Character.getNumericValue(num) + 1;
      value = value.substring(0, value.length() - 1) + i;
      result.add(value);
      return true;
    });

    assertFalse(result.isEmpty());
    for (int i = 0; i < result.size(); i++) {
      assertEquals("b-value" + (i + 1), result.get(i));
    }

    // iterate from a non exist key
    result.clear();
    store.iterate(getBytes("xyz"), (k, v) -> {
      result.add(getString(v));
      return true;
    });
    assertTrue(result.isEmpty());

    // iterate from the beginning
    result.clear();
    store.iterate(null, (k, v) -> {
      result.add(getString(v));
      return true;
    });
    assertEquals(20, result.size());
  }

  @Test
  public void testGetRangeKVs() throws IOException {
    List<Map.Entry<byte[], byte[]>> result = null;

    // Set empty startKey will return values from beginning.
    result = store.getRangeKVs(null, 5);
    assertEquals(5, result.size());
    assertEquals("a-value2", getString(result.get(2).getValue()));

    // Empty list if startKey doesn't exist.
    result = store.getRangeKVs(getBytes("a12"), 5);
    assertEquals(0, result.size());

    // Returns max available entries after a valid startKey.
    result = store.getRangeKVs(getBytes("b0"), MAX_GETRANGE_LENGTH);
    assertEquals(10, result.size());
    assertEquals("b0", getString(result.get(0).getKey()));
    assertEquals("b-value0", getString(result.get(0).getValue()));
    result = store.getRangeKVs(getBytes("b0"), 5);
    assertEquals(5, result.size());

    // Both startKey and count are honored.
    result = store.getRangeKVs(getBytes("a9"), 2);
    assertEquals(2, result.size());
    assertEquals("a9", getString(result.get(0).getKey()));
    assertEquals("a-value9", getString(result.get(0).getValue()));
    assertEquals("b0", getString(result.get(1).getKey()));
    assertEquals("b-value0", getString(result.get(1).getValue()));

    // Filter keys by prefix.
    // It should returns all "b*" entries.
    MetadataKeyFilter filter1 = new KeyPrefixFilter().addFilter("b");
    result = store.getRangeKVs(null, 100, filter1);
    assertEquals(10, result.size());
    assertTrue(result.stream().allMatch(entry ->
        new String(entry.getKey(), UTF_8).startsWith("b")
    ));
    assertEquals(20, filter1.getKeysScannedNum());
    assertEquals(10, filter1.getKeysHintedNum());
    result = store.getRangeKVs(null, 3, filter1);
    assertEquals(3, result.size());
    result = store.getRangeKVs(getBytes("b3"), 1, filter1);
    assertEquals("b-value3", getString(result.get(0).getValue()));

    // Define a customized filter that filters keys by suffix.
    // Returns all "*2" entries.
    MetadataKeyFilter filter2 = (preKey, currentKey, nextKey)
        -> getString(currentKey).endsWith("2");
    result = store.getRangeKVs(null, MAX_GETRANGE_LENGTH, filter2);
    assertEquals(2, result.size());
    assertEquals("a2", getString(result.get(0).getKey()));
    assertEquals("b2", getString(result.get(1).getKey()));
    result = store.getRangeKVs(null, 1, filter2);
    assertEquals(1, result.size());
    assertEquals("a2", getString(result.get(0).getKey()));

    // Apply multiple filters.
    result = store.getRangeKVs(null, MAX_GETRANGE_LENGTH, filter1, filter2);
    assertEquals(1, result.size());
    assertEquals("b2", getString(result.get(0).getKey()));
    assertEquals("b-value2", getString(result.get(0).getValue()));

    // If filter is null, no effect.
    result = store.getRangeKVs(null, 1, (MetadataKeyFilter[]) null);
    assertEquals(1, result.size());
    assertEquals("a0", getString(result.get(0).getKey()));
  }

  @Test
  public void testGetSequentialRangeKVs() throws IOException {
    MetadataKeyFilter suffixFilter = (preKey, currentKey, nextKey)
        -> StringUtils.bytes2String(currentKey).endsWith("2");
    // Suppose to return a2 and b2
    List<Map.Entry<byte[], byte[]>> result =
        store.getRangeKVs(null, MAX_GETRANGE_LENGTH, suffixFilter);
    assertEquals(2, result.size());
    assertEquals("a2", StringUtils.bytes2String(result.get(0).getKey()));
    assertEquals("b2", StringUtils.bytes2String(result.get(1).getKey()));

    // Suppose to return just a2, because when it iterates to a3,
    // the filter no long matches and it should stop from there.
    result = store.getSequentialRangeKVs(null,
        MAX_GETRANGE_LENGTH, suffixFilter);
    assertEquals(1, result.size());
    assertEquals("a2", StringUtils.bytes2String(result.get(0).getKey()));
  }

  @Test
  public void testGetRangeLength() throws IOException {
    List<Map.Entry<byte[], byte[]>> result = null;

    result = store.getRangeKVs(null, 0);
    assertEquals(0, result.size());

    result = store.getRangeKVs(null, 1);
    assertEquals(1, result.size());

    // Count less than zero is invalid.
    expectedException.expect(IllegalArgumentException.class);
    expectedException.expectMessage("Invalid count given");
    store.getRangeKVs(null, -1);
  }

  @Test
  public void testInvalidStartKey() throws IOException {
    // If startKey is invalid, the returned list should be empty.
    List<Map.Entry<byte[], byte[]>> kvs =
        store.getRangeKVs(getBytes("unknownKey"), MAX_GETRANGE_LENGTH);
    assertEquals(0, kvs.size());
  }

  @Test
  public void testDestroyDB() throws IOException {
    // create a new DB to test db destroy
    OzoneConfiguration conf = new OzoneConfiguration();
    conf.set(OzoneConfigKeys.OZONE_METADATA_STORE_IMPL, storeImpl);

    File dbDir = GenericTestUtils.getTestDir(getClass().getSimpleName()
        + "-" + storeImpl.toLowerCase() + "-toDestroy");
    MetadataStore dbStore = MetadataStoreBuilder.newBuilder()
        .setConf(conf)
        .setCreateIfMissing(true)
        .setDbFile(dbDir)
        .build();

    dbStore.put(getBytes("key1"), getBytes("value1"));
    dbStore.put(getBytes("key2"), getBytes("value2"));

    assertFalse(dbStore.isEmpty());
    assertTrue(dbDir.exists());
    assertTrue(dbDir.listFiles().length > 0);

    dbStore.destroy();

    assertFalse(dbDir.exists());
  }

  @Test
  public void testBatchWrite() throws IOException {
    OzoneConfiguration conf = new OzoneConfiguration();
    conf.set(OzoneConfigKeys.OZONE_METADATA_STORE_IMPL, storeImpl);

    File dbDir = GenericTestUtils.getTestDir(getClass().getSimpleName()
        + "-" + storeImpl.toLowerCase() + "-batchWrite");
    MetadataStore dbStore = MetadataStoreBuilder.newBuilder()
        .setConf(conf)
        .setCreateIfMissing(true)
        .setDbFile(dbDir)
        .build();

    List<String> expectedResult = Lists.newArrayList();
    for (int i = 0; i < 10; i++) {
      dbStore.put(getBytes("batch-" + i), getBytes("batch-value-" + i));
      expectedResult.add("batch-" + i);
    }

    BatchOperation batch = new BatchOperation();
    batch.delete(getBytes("batch-2"));
    batch.delete(getBytes("batch-3"));
    batch.delete(getBytes("batch-4"));
    batch.put(getBytes("batch-new-2"), getBytes("batch-new-value-2"));

    expectedResult.remove("batch-2");
    expectedResult.remove("batch-3");
    expectedResult.remove("batch-4");
    expectedResult.add("batch-new-2");

    dbStore.writeBatch(batch);

    Iterator<String> it = expectedResult.iterator();
    AtomicInteger count = new AtomicInteger(0);
    dbStore.iterate(null, (key, value) -> {
      count.incrementAndGet();
      return it.hasNext() && it.next().equals(getString(key));
    });

    assertEquals(8, count.get());
  }

  @Test
  public void testKeyPrefixFilter() throws IOException {
    List<Map.Entry<byte[], byte[]>> result = null;
    RuntimeException exception = null;

    try {
      new KeyPrefixFilter().addFilter("b0", true).addFilter("b");
    } catch (IllegalArgumentException e) {
      exception = e;
      assertTrue(exception.getMessage().contains("KeyPrefix: b already " +
          "rejected"));
    }

    try {
      new KeyPrefixFilter().addFilter("b0").addFilter("b", true);
    } catch (IllegalArgumentException e) {
      exception = e;
      assertTrue(exception.getMessage().contains("KeyPrefix: b already " +
          "accepted"));
    }

    try {
      new KeyPrefixFilter().addFilter("b", true).addFilter("b0");
    } catch (IllegalArgumentException e) {
      exception = e;
      assertTrue(exception.getMessage().contains("KeyPrefix: b0 already " +
          "rejected"));
    }

    try {
      new KeyPrefixFilter().addFilter("b").addFilter("b0", true);
    } catch (IllegalArgumentException e) {
      exception = e;
      assertTrue(exception.getMessage().contains("KeyPrefix: b0 already " +
          "accepted"));
    }

    MetadataKeyFilter filter1 = new KeyPrefixFilter(true)
        .addFilter("a0")
        .addFilter("a1")
        .addFilter("b", true);
    result = store.getRangeKVs(null, 100, filter1);
    assertEquals(2, result.size());
    assertTrue(result.stream().anyMatch(entry -> new String(entry.getKey(),
        UTF_8)
        .startsWith("a0")) && result.stream().anyMatch(entry -> new String(
        entry.getKey(), UTF_8).startsWith("a1")));

    filter1 = new KeyPrefixFilter(true).addFilter("b", true);
    result = store.getRangeKVs(null, 100, filter1);
    assertEquals(0, result.size());

    filter1 = new KeyPrefixFilter().addFilter("b", true);
    result = store.getRangeKVs(null, 100, filter1);
    assertEquals(10, result.size());
    assertTrue(result.stream().allMatch(entry -> new String(entry.getKey(),
        UTF_8)
        .startsWith("a")));
  }
}