/*
 * Copyright 2018, Oath Inc
 * Licensed under the terms of the Apache License 2.0. Please refer to accompanying LICENSE file for terms.
 */

package com.oath.halodb;

import com.google.common.primitives.Longs;

import java.io.File;
import java.nio.ByteBuffer;
import java.util.*;
import org.testng.Assert;
import org.testng.annotations.Test;

import java.io.IOException;
import java.nio.file.Paths;

import mockit.Mock;
import mockit.MockUp;

public class HaloDBTest extends TestBase {

    @Test(dataProvider = "Options")
    public void testPutAndGetDB(HaloDBOptions options) throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testPutAndGetDB");

        options.setCompactionDisabled(true);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);

        List<Record> actual = new ArrayList<>();
        db.newIterator().forEachRemaining(actual::add);

        Assert.assertTrue(actual.containsAll(records) && records.containsAll(actual));

        records.forEach(record -> {
            try {
                byte[] value = db.get(record.getKey());
                Assert.assertEquals(record.getValue(), value);
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test(dataProvider = "Options")
    public void testPutUpdateAndGetDB(HaloDBOptions options) throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testPutUpdateAndGetDB");

        options.setCompactionDisabled(true);
        options.setMaxFileSize(10 * 1024);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);

        List<Record> updated = TestUtils.updateRecords(db, records);

        List<Record> actual = new ArrayList<>();
        db.newIterator().forEachRemaining(actual::add);

        Assert.assertTrue(actual.containsAll(updated) && updated.containsAll(actual));

        updated.forEach(record -> {
            try {
                byte[] value = db.get(record.getKey());
                Assert.assertEquals(record.getValue(), value);
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test(dataProvider = "Options")
    public void testCreateCloseAndOpenDB(HaloDBOptions options) throws HaloDBException {

        String directory = TestUtils.getTestDirectory("HaloDBTest", "testCreateCloseAndOpenDB");

        options.setCompactionDisabled(true);
        options.setMaxFileSize(10 * 1024);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);

        // update half the records.
        for (int i = 0; i < records.size(); i++) {
            if (i % 2 == 0) {
                Record record = records.get(i);
                try {
                    byte[] value = TestUtils.generateRandomByteArray();
                    db.put(record.getKey(), value);
                    records.set(i, new Record(record.getKey(), value));
                } catch (HaloDBException e) {
                    throw new RuntimeException(e);
                }
            }

        }

        db.close();

        // open and read contents again.
        HaloDB openAgainDB = getTestDBWithoutDeletingFiles(directory, options);

        List<Record> actual = new ArrayList<>();
        openAgainDB.newIterator().forEachRemaining(actual::add);

        Assert.assertTrue(actual.containsAll(records) && records.containsAll(actual));

        records.forEach(record -> {
            try {
                byte[] value = openAgainDB.get(record.getKey());
                Assert.assertEquals(record.getValue(), value);
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test(dataProvider = "Options")
    public void testSyncWrite(HaloDBOptions options) throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testSyncWrite");

        options.setCompactionDisabled(true);
        options.setMaxFileSize(10 * 1024);
        options.enableSyncWrites(true);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);
        List<Record> updated = TestUtils.updateRecords(db, records);

        List<Record> actual = new ArrayList<>();
        db.newIterator().forEachRemaining(actual::add);

        Assert.assertTrue(actual.containsAll(updated) && updated.containsAll(actual));

        updated.forEach(record -> {
            try {
                byte[] value = db.get(record.getKey());
                Assert.assertEquals(record.getValue(), value);
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test(dataProvider = "Options")
    public void testToCheckThatLatestUpdateIsPickedAfterDBOpen(HaloDBOptions options) throws HaloDBException {

        String directory = TestUtils.getTestDirectory("HaloDBTest", "testToCheckThatLatestUpdateIsPickedAfterDBOpen");

        options.setCompactionDisabled(true);

        // sized to ensure that there will be two files.
        options.setMaxFileSize(1500);

        HaloDB db = getTestDB(directory, options);

        byte[] key = TestUtils.generateRandomByteArray(7);
        byte[] value = null;

        // update the same record 100 times.
        // each key-value pair with the metadata is 20 bytes.
        // therefore 20 * 100 = 2000 bytes
        for (int i = 0; i < 100; i++) {
            value = TestUtils.generateRandomByteArray(7);
            db.put(key, value);
        }

        db.close();

        // open and read contents again.
        HaloDB openAgainDB = getTestDBWithoutDeletingFiles(directory, options);

        List<Record> actual = new ArrayList<>();
        openAgainDB.newIterator().forEachRemaining(actual::add);

        Assert.assertTrue(actual.size() == 1);

        Assert.assertEquals(openAgainDB.get(key), value);
    }

    @Test(dataProvider = "Options")
    public void testToCheckDelete(HaloDBOptions options) throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testToCheckDelete");

        options.setCompactionDisabled(true);
        options.setMaxFileSize(10 * 1024);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);

        List<Record> deleted = new ArrayList<>();
        for (int i = 0; i < noOfRecords; i++) {
            if (i % 10 == 0) deleted.add(records.get(i));
        }

        TestUtils.deleteRecords(db, deleted);

        List<Record> remaining = new ArrayList<>();
        db.newIterator().forEachRemaining(remaining::add);

        Assert.assertTrue(remaining.size() == noOfRecords - deleted.size());

        deleted.forEach(r -> {
            try {
                Assert.assertNull(db.get(r.getKey()));
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test(dataProvider = "Options")
    public void testDeleteCloseAndOpen(HaloDBOptions options) throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testDeleteCloseAndOpen");

        options.setCompactionDisabled(true);
        options.setMaxFileSize(10 * 1024);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);

        List<Record> deleted = new ArrayList<>();
        for (int i = 0; i < noOfRecords; i++) {
            if (i % 10 == 0) deleted.add(records.get(i));
        }

        TestUtils.deleteRecords(db, deleted);

        db.close();

        HaloDB openAgainDB = getTestDBWithoutDeletingFiles(directory, options);

        List<Record> remaining = new ArrayList<>();
        openAgainDB.newIterator().forEachRemaining(remaining::add);

        Assert.assertTrue(remaining.size() == noOfRecords - deleted.size());

        deleted.forEach(r -> {
            try {
                Assert.assertNull(openAgainDB.get(r.getKey()));
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test(dataProvider = "Options")
    public void testDeleteAndInsert(HaloDBOptions options) throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testDeleteAndInsert");

        options.setCompactionDisabled(true);
        options.setMaxFileSize(10 * 1024);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);

        List<Record> deleted = new ArrayList<>();
        for (int i = 0; i < noOfRecords; i++) {
            if (i % 10 == 0) deleted.add(records.get(i));
        }

        TestUtils.deleteRecords(db, deleted);

        List<Record> deleteAndInsert = new ArrayList<>();
        deleted.forEach(r -> {
            try {
                byte[] value = TestUtils.generateRandomByteArray();
                db.put(r.getKey(), value);
                deleteAndInsert.add(new Record(r.getKey(), value));
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });


        List<Record> remaining = new ArrayList<>();
        db.newIterator().forEachRemaining(remaining::add);

        Assert.assertTrue(remaining.size() == noOfRecords);

        deleteAndInsert.forEach(r -> {
            try {
                Assert.assertEquals(r.getValue(), db.get(r.getKey()));
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test(dataProvider = "Options")
    public void testDeleteInsertCloseAndOpen(HaloDBOptions options) throws HaloDBException {
        String directory = TestUtils.getTestDirectory("tmp", "testDeleteInsertCloseAndOpen");

        options.setCompactionDisabled(true);
        options.setMaxFileSize(10 * 1024);

        HaloDB db = getTestDB(directory, options);

        int noOfRecords = 10_000;
        List<Record> records = TestUtils.insertRandomRecords(db, noOfRecords);

        List<Record> deleted = new ArrayList<>();
        for (int i = 0; i < noOfRecords; i++) {
            if (i % 10 == 0) deleted.add(records.get(i));
        }

        TestUtils.deleteRecords(db, deleted);

        // inserting deleted records again. 
        List<Record> deleteAndInsert = new ArrayList<>();
        deleted.forEach(r -> {
            try {
                byte[] value = TestUtils.generateRandomByteArray();
                db.put(r.getKey(), value);
                deleteAndInsert.add(new Record(r.getKey(), value));
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });

        db.close();
        HaloDB openAgainDB = getTestDBWithoutDeletingFiles(directory, options);

        List<Record> remaining = new ArrayList<>();
        openAgainDB.newIterator().forEachRemaining(remaining::add);

        Assert.assertTrue(remaining.size() == noOfRecords);

        // make sure that records that were earlier deleted shows up now, since they were put back later.
        deleteAndInsert.forEach(r -> {
            try {
                Assert.assertEquals(r.getValue(), openAgainDB.get(r.getKey()));
            } catch (HaloDBException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Test
    public void testDBMetaFile() throws HaloDBException, IOException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testDBMetaFile");

        HaloDBOptions options = new HaloDBOptions();
        int maxFileSize = 1024 * 1024 * 1024;
        options.setMaxFileSize(maxFileSize);
        HaloDB db = getTestDB(directory, options);

        // Make sure that the META file was written.
        Assert.assertTrue(Paths.get(directory, DBMetaData.METADATA_FILE_NAME).toFile().exists());

        DBMetaData metaData = new DBMetaData(dbDirectory);
        metaData.loadFromFileIfExists();

        // Make sure that the open flag was set on db open.
        Assert.assertTrue(metaData.isOpen());

        // Default value of ioError flag must be false. 
        Assert.assertFalse(metaData.isIOError());

        // since we just created the db max file size should be set to one we set in HaloDBOptions
        Assert.assertEquals(metaData.getMaxFileSize(), maxFileSize);
        Assert.assertEquals(metaData.getVersion(), Versions.CURRENT_META_FILE_VERSION);

        db.close();

        // Make sure that the META file was written.
        Assert.assertTrue(Paths.get(directory, DBMetaData.METADATA_FILE_NAME).toFile().exists());

        // Make sure that the flags were set correctly on close.
        metaData.loadFromFileIfExists();

        Assert.assertEquals(metaData.getVersion(), Versions.CURRENT_META_FILE_VERSION);
        Assert.assertFalse(metaData.isOpen());
        Assert.assertFalse(metaData.isIOError());
        Assert.assertEquals(metaData.getMaxFileSize(), maxFileSize);
    }

    @Test(expectedExceptions = IllegalArgumentException.class, expectedExceptionsMessageRegExp = "File size cannot be changed after db was created.*")
    public void testMaxFileSize() throws HaloDBException, IOException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testMaxFileSize");

        HaloDBOptions options = new HaloDBOptions();
        int maxFileSize = 1024 * 1024 * 1024;
        options.setMaxFileSize(maxFileSize);
        HaloDB db = getTestDB(directory, options);

        DBMetaData metaData = new DBMetaData(dbDirectory);
        metaData.loadFromFileIfExists();

        Assert.assertEquals(metaData.getMaxFileSize(), maxFileSize);

        db.close();

        // try opening the db with max file size changed.
        options.setMaxFileSize(500 * 1024 * 1024);
        getTestDBWithoutDeletingFiles(directory, options);
    }

    @Test(expectedExceptions = HaloDBException.class, expectedExceptionsMessageRegExp = "Another process already holds a lock for this db.")
    public void testLock() throws Throwable {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testLock");

        HaloDB db = getTestDB(directory, new HaloDBOptions());
        db.resetStats();
        HaloDB anotherDB = HaloDB.open(directory, new HaloDBOptions());
        anotherDB.resetStats();
    }

    @Test
    public void testLockReleaseOnError() throws Throwable {

        new MockUp<DBMetaData>() {
            int count = 0;

            @Mock
            void loadFromFileIfExists() throws IOException {
                System.out.println("Mock called");
                if (count == 0) {
                    // throw an exception the first time the method is called.
                    count = 1;
                    throw new IOException();
                }
            }
        };

        String directory = TestUtils.getTestDirectory("HaloDBTest", "testLockReleaseOnError");

        HaloDB db = null;
        try {
            db = getTestDB(directory, new HaloDBOptions());
        } catch (HaloDBException e) {
            // swallow the mocked exception. 
        }
        // make sure open() failed.
        Assert.assertNull(db);

        // the lock should have been released when previous open() failed. 
        HaloDB anotherDB = getTestDBWithoutDeletingFiles(directory, new HaloDBOptions());
        anotherDB.put(Longs.toByteArray(1), Longs.toByteArray(1));
        Assert.assertEquals(anotherDB.size(), 1);
    }

    @Test(expectedExceptions = HaloDBException.class)
    public void testPutAfterClose() throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testPutAfterClose");
        HaloDB db = getTestDB(directory, new HaloDBOptions());
        db.put(Longs.toByteArray(1), Longs.toByteArray(1));
        db.close();
        db.put(Longs.toByteArray(2), Longs.toByteArray(2));
    }

    @Test(expectedExceptions = HaloDBException.class)
    public void testDeleteAfterClose() throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testDeleteAfterClose");

        HaloDB db = getTestDB(directory, new HaloDBOptions());
        db.put(Longs.toByteArray(1), Longs.toByteArray(1));
        db.put(Longs.toByteArray(2), Longs.toByteArray(2));
        db.delete(Longs.toByteArray(1));
        db.close();
        db.delete(Longs.toByteArray(2));
    }

    @Test(expectedExceptions = NullPointerException.class)
    public void testPutAfterCloseWithoutWrites() throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testPutAfterCloseWithoutWrites");
        HaloDB db = getTestDB(directory, new HaloDBOptions());
        db.close();
        db.put(Longs.toByteArray(1), Longs.toByteArray(1));
    }

    @Test(expectedExceptions = NullPointerException.class)
    public void testDeleteAfterCloseWithoutWrites() throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testDeleteAfterCloseWithoutWrites");

        HaloDB db = getTestDB(directory, new HaloDBOptions());
        db.put(Longs.toByteArray(1), Longs.toByteArray(1));
        Assert.assertEquals(db.get(Longs.toByteArray(1)), Longs.toByteArray(1));
        db.close();
        db.delete(Longs.toByteArray(1));
    }

    @Test
    public void testSnapshot() throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testSnapshot");
        HaloDBOptions options = new HaloDBOptions();
        // Generate several data files
        options.setMaxFileSize(10000);
        HaloDB db = getTestDB(directory, options);
        for (int i = 10000; i < 10000 + 10*1000; i++) {
            db.put(ByteBuffer.allocate(4).putInt(i).array(), ByteBuffer.allocate(8).putInt(i).putInt(i*1024).array());
        }
        db.snapshot();

        String snapshotDir = db.getSnapshotDirectory().toString();
        HaloDB snapshotDB = getTestDBWithoutDeletingFiles(snapshotDir, options);

        for (int i = 10000; i < 10000 + 10*1000; i++) {
            byte[] value = snapshotDB.get(ByteBuffer.allocate(4).putInt(i).array());
            Assert.assertTrue(Arrays.equals(value, ByteBuffer.allocate(8).putInt(i).putInt(i*1024).array()));
        }

        snapshotDB.close();
        db.close();
    }

    @Test
    public void testCreateAndDeleteSnapshot() throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testCreateAndDeleteSnapshot");
        HaloDBOptions options = new HaloDBOptions();
        // Generate several data files
        options.setMaxFileSize(10000);
        HaloDB db = getTestDB(directory, options);
        for (int i = 10000; i < 10000 + 10*1000; i++) {
            db.put(ByteBuffer.allocate(4).putInt(i).array(), ByteBuffer.allocate(8).putInt(i).putInt(i*1024).array());
        }

        Assert.assertTrue(db.clearSnapshot());
        db.snapshot();

        Assert.assertTrue(db.clearSnapshot());

        File snapshotDir = db.getSnapshotDirectory();
        Assert.assertFalse(snapshotDir.exists());

        db.close();
    }

    @Test
    public void testSnapshotAfterBeenCompacted() throws HaloDBException {
        String directory = TestUtils.getTestDirectory("HaloDBTest", "testSnapshotAfterBeenCompacted");
        HaloDBOptions options = new HaloDBOptions();
        // Generate several data files
        options.setMaxFileSize(10000);
        options.setCompactionThresholdPerFile(0.7);
        HaloDB db = getTestDB(directory, options);
        for (int i = 10000; i < 10000 + 10*1000; i++) {
            db.put(ByteBuffer.allocate(4).putInt(i).array(), ByteBuffer.allocate(8).putInt(i).putInt(i*1024).array());
        }
        db.snapshot();

        // overwrite all previous record
        for (int i = 10000; i < 10000 + 10*1000; i++) {
            db.put(ByteBuffer.allocate(4).putInt(i).array(), ByteBuffer.allocate(8).putInt(i).putInt(i*2048).array());
        }
        TestUtils.waitForCompactionToComplete(db);

        String snapshotDir = db.getSnapshotDirectory().toString();
        HaloDB snapshotDB = getTestDBWithoutDeletingFiles(snapshotDir, options);

        for (int i = 10000; i < 10000 + 10*1000; i++) {
            byte[] key = ByteBuffer.allocate(4).putInt(i).array();
            byte[] value = snapshotDB.get(key);
            Assert.assertTrue(Arrays.equals(value, ByteBuffer.allocate(8).putInt(i).putInt(i*1024).array()));
            Assert.assertFalse(Arrays.equals(value, db.get(key)));
        }

        snapshotDB.close();
        db.close();
    }

}