/*
 * 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.flume.channel.file;

import com.google.common.collect.Lists;
import com.google.common.io.Files;
import org.apache.commons.io.FileUtils;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.List;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class TestLog {
  private static final Logger LOGGER = LoggerFactory.getLogger(TestLog.class);
  private static final long MAX_FILE_SIZE = 1000;
  private static final int CAPACITY = 10000;
  private Log log;
  private File checkpointDir;
  private File[] dataDirs;
  private long transactionID;

  @Before
  public void setup() throws IOException {
    transactionID = 0;
    checkpointDir = Files.createTempDir();
    FileUtils.forceDeleteOnExit(checkpointDir);
    Assert.assertTrue(checkpointDir.isDirectory());
    dataDirs = new File[3];
    for (int i = 0; i < dataDirs.length; i++) {
      dataDirs[i] = Files.createTempDir();
      Assert.assertTrue(dataDirs[i].isDirectory());
    }
    log = new Log.Builder().setCheckpointInterval(1L)
                           .setMaxFileSize(MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setCheckpointOnClose(false)
                           .setChannelName("testlog")
                           .build();
    log.replay();
  }

  @After
  public void cleanup() throws Exception {
    if (log != null) {
      log.close();
    }
    FileUtils.deleteQuietly(checkpointDir);
    for (int i = 0; i < dataDirs.length; i++) {
      FileUtils.deleteQuietly(dataDirs[i]);
    }
  }

  /**
   * Test that we can put, commit and then get. Note that get is
   * not transactional so the commit is not required.
   */
  @Test
  public void testPutGet()
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long transactionID = ++this.transactionID;
    FlumeEventPointer eventPointer = log.put(transactionID, eventIn);
    log.commitPut(transactionID); // this is not required since
    // get is not transactional
    FlumeEvent eventOut = log.get(eventPointer);
    Assert.assertNotNull(eventOut);
    Assert.assertEquals(eventIn.getHeaders(), eventOut.getHeaders());
    Assert.assertArrayEquals(eventIn.getBody(), eventOut.getBody());
  }

  @Test
  public void testRoll()
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    log.shutdownWorker();
    Thread.sleep(1000);
    for (int i = 0; i < 1000; i++) {
      FlumeEvent eventIn = TestUtils.newPersistableEvent();
      long transactionID = ++this.transactionID;
      FlumeEventPointer eventPointer = log.put(transactionID, eventIn);
      // get is not transactional
      FlumeEvent eventOut = log.get(eventPointer);
      Assert.assertNotNull(eventOut);
      Assert.assertEquals(eventIn.getHeaders(), eventOut.getHeaders());
      Assert.assertArrayEquals(eventIn.getBody(), eventOut.getBody());
    }
    int logCount = 0;
    for (File dataDir : dataDirs) {
      for (File logFile : dataDir.listFiles()) {
        if (logFile.getName().startsWith("log-")) {
          logCount++;
        }
      }
    }
    // 93 (*2 for meta) files with TestLog.MAX_FILE_SIZE=1000
    Assert.assertEquals(186, logCount);
  }

  /**
   * After replay of the log, we should find the event because the put
   * was committed
   */
  @Test
  public void testPutCommit()
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long transactionID = ++this.transactionID;
    FlumeEventPointer eventPointerIn = log.put(transactionID, eventIn);
    log.commitPut(transactionID);
    log.close();
    log = new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                           .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .build();
    log.replay();
    takeAndVerify(eventPointerIn, eventIn);
  }

  /**
   * After replay of the log, we should not find the event because the
   * put was rolled back
   */
  @Test
  public void testPutRollback() throws IOException, InterruptedException {
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long transactionID = ++this.transactionID;
    log.put(transactionID, eventIn);
    log.rollback(transactionID); // rolled back so it should not be replayed
    log.close();
    log = new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                           .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .build();
    log.replay();
    FlumeEventQueue queue = log.getFlumeEventQueue();
    Assert.assertNull(queue.removeHead(transactionID));
  }

  @Test
  public void testMinimumRequiredSpaceTooSmallOnStartup() throws IOException,
                                                                     InterruptedException {
    log.close();
    log = new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                           .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .setMinimumRequiredSpace(Long.MAX_VALUE)
                           .build();
    try {
      log.replay();
      Assert.fail();
    } catch (IOException e) {
      Assert.assertTrue(e.getMessage(),
                        e.getMessage().startsWith("Usable space exhausted"));
    }
  }

  /**
   * There is a race here in that someone could take up some space
   */
  @Test
  public void testMinimumRequiredSpaceTooSmallForPut() throws IOException, InterruptedException {
    try {
      doTestMinimumRequiredSpaceTooSmallForPut();
    } catch (IOException e) {
      LOGGER.info("Error during test, retrying", e);
      doTestMinimumRequiredSpaceTooSmallForPut();
    } catch (AssertionError e) {
      LOGGER.info("Test failed, let's be sure it failed for good reason", e);
      doTestMinimumRequiredSpaceTooSmallForPut();
    }
  }

  public void doTestMinimumRequiredSpaceTooSmallForPut() throws IOException,
                                                                    InterruptedException {
    long minimumRequiredSpace = checkpointDir.getUsableSpace() -
                                    (10L * 1024L * 1024L);
    log.close();
    log = new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                           .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .setMinimumRequiredSpace(minimumRequiredSpace)
                           .setUsableSpaceRefreshInterval(1L)
                           .build();
    log.replay();
    File filler = new File(checkpointDir, "filler");
    byte[] buffer = new byte[64 * 1024];
    FileOutputStream out = new FileOutputStream(filler);
    while (checkpointDir.getUsableSpace() > minimumRequiredSpace) {
      out.write(buffer);
    }
    out.close();
    try {
      FlumeEvent eventIn = TestUtils.newPersistableEvent();
      long transactionID = ++this.transactionID;
      log.put(transactionID, eventIn);
      Assert.fail();
    } catch (IOException e) {
      Assert.assertTrue(e.getMessage(),
                        e.getMessage().startsWith("Usable space exhausted"));
    }
  }

  /**
   * After replay of the log, we should not find the event because the take
   * was committed
   */
  @Test
  public void testPutTakeCommit() throws IOException, InterruptedException {
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long putTransactionID = ++transactionID;
    FlumeEventPointer eventPointer = log.put(putTransactionID, eventIn);
    log.commitPut(putTransactionID);
    long takeTransactionID = ++transactionID;
    log.take(takeTransactionID, eventPointer);
    log.commitTake(takeTransactionID);
    log.close();
    new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                     .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                     .setQueueSize(1)
                     .setCheckpointDir(checkpointDir)
                     .setLogDirs(dataDirs)
                     .setChannelName("testlog")
                     .build();
    log.replay();
    FlumeEventQueue queue = log.getFlumeEventQueue();
    Assert.assertNull(queue.removeHead(0));
  }

  /**
   * After replay of the log, we should get the event because the take
   * was rolled back
   */
  @Test
  public void testPutTakeRollbackLogReplayV1()
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    doPutTakeRollback(true);
  }

  @Test
  public void testPutTakeRollbackLogReplayV2()
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    doPutTakeRollback(false);
  }

  public void doPutTakeRollback(boolean useLogReplayV1)
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long putTransactionID = ++transactionID;
    FlumeEventPointer eventPointerIn = log.put(putTransactionID, eventIn);
    log.commitPut(putTransactionID);
    long takeTransactionID = ++transactionID;
    log.take(takeTransactionID, eventPointerIn);
    log.rollback(takeTransactionID);
    log.close();
    new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                     .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                     .setQueueSize(1)
                     .setCheckpointDir(checkpointDir)
                     .setLogDirs(dataDirs)
                     .setChannelName("testlog")
                     .setUseLogReplayV1(useLogReplayV1)
                     .build();
    log.replay();
    takeAndVerify(eventPointerIn, eventIn);
  }

  @Test
  public void testCommitNoPut() throws IOException, InterruptedException {
    long putTransactionID = ++transactionID;
    log.commitPut(putTransactionID);
    log.close();
    new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                     .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                     .setQueueSize(1)
                     .setCheckpointDir(checkpointDir)
                     .setLogDirs(dataDirs)
                     .setChannelName("testlog")
                     .build();
    log.replay();
    FlumeEventQueue queue = log.getFlumeEventQueue();
    FlumeEventPointer eventPointerOut = queue.removeHead(0);
    Assert.assertNull(eventPointerOut);
  }

  @Test
  public void testCommitNoTake() throws IOException, InterruptedException {
    long putTransactionID = ++transactionID;
    log.commitTake(putTransactionID);
    log.close();
    new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                     .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                     .setQueueSize(1)
                     .setCheckpointDir(checkpointDir)
                     .setLogDirs(dataDirs)
                     .setChannelName("testlog")
                     .build();
    log.replay();
    FlumeEventQueue queue = log.getFlumeEventQueue();
    FlumeEventPointer eventPointerOut = queue.removeHead(0);
    Assert.assertNull(eventPointerOut);
  }

  @Test
  public void testRollbackNoPutTake() throws IOException, InterruptedException {
    long putTransactionID = ++transactionID;
    log.rollback(putTransactionID);
    log.close();
    new Log.Builder().setCheckpointInterval(Long.MAX_VALUE)
                     .setMaxFileSize(FileChannelConfiguration.DEFAULT_MAX_FILE_SIZE)
                     .setQueueSize(1)
                     .setCheckpointDir(checkpointDir)
                     .setLogDirs(dataDirs)
                     .setChannelName("testlog")
                     .build();
    log.replay();
    FlumeEventQueue queue = log.getFlumeEventQueue();
    FlumeEventPointer eventPointerOut = queue.removeHead(0);
    Assert.assertNull(eventPointerOut);
  }

  @Test
  public void testGetLogs() throws IOException {
    File logDir = dataDirs[0];
    List<File> expected = Lists.newArrayList();
    for (int i = 0; i < 10; i++) {
      File log = new File(logDir, Log.PREFIX + i);
      expected.add(log);
      Assert.assertTrue(log.isFile() || log.createNewFile());
      File metaDataFile = Serialization.getMetaDataFile(log);
      File metaDataTempFile = Serialization.getMetaDataTempFile(metaDataFile);
      File logGzip = new File(logDir, Log.PREFIX + i + ".gz");
      Assert.assertTrue(metaDataFile.isFile() || metaDataFile.createNewFile());
      Assert.assertTrue(metaDataTempFile.isFile() ||
                            metaDataTempFile.createNewFile());
      Assert.assertTrue(log.isFile() || logGzip.createNewFile());
    }
    List<File> actual = LogUtils.getLogs(logDir);
    LogUtils.sort(actual);
    LogUtils.sort(expected);
    Assert.assertEquals(expected, actual);
  }

  @Test
  public void testReplayFailsWithAllEmptyLogMetaDataNormalReplay()
      throws IOException, InterruptedException {
    doTestReplayFailsWithAllEmptyLogMetaData(false);
  }

  @Test
  public void testReplayFailsWithAllEmptyLogMetaDataFastReplay()
      throws IOException, InterruptedException {
    doTestReplayFailsWithAllEmptyLogMetaData(true);
  }

  public void doTestReplayFailsWithAllEmptyLogMetaData(boolean useFastReplay)
      throws IOException, InterruptedException {
    // setup log with correct fast replay parameter
    log.close();
    log = new Log.Builder().setCheckpointInterval(1L)
                           .setMaxFileSize(MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .setUseFastReplay(useFastReplay)
                           .build();
    log.replay();
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long transactionID = ++this.transactionID;
    log.put(transactionID, eventIn);
    log.commitPut(transactionID);
    log.close();
    if (useFastReplay) {
      FileUtils.deleteQuietly(checkpointDir);
      Assert.assertTrue(checkpointDir.mkdir());
    }
    List<File> logFiles = Lists.newArrayList();
    for (int i = 0; i < dataDirs.length; i++) {
      logFiles.addAll(LogUtils.getLogs(dataDirs[i]));
    }
    Assert.assertTrue(logFiles.size() > 0);
    for (File logFile : logFiles) {
      File logFileMeta = Serialization.getMetaDataFile(logFile);
      Assert.assertTrue(logFileMeta.delete());
      Assert.assertTrue(logFileMeta.createNewFile());
    }
    log = new Log.Builder().setCheckpointInterval(1L)
                           .setMaxFileSize(MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .setUseFastReplay(useFastReplay)
                           .build();
    try {
      log.replay();
      Assert.fail();
    } catch (IllegalStateException expected) {
      String msg = expected.getMessage();
      Assert.assertNotNull(msg);
      Assert.assertTrue(msg, msg.contains(".meta is empty, but log"));
    }
  }

  @Test
  public void testReplaySucceedsWithUnusedEmptyLogMetaDataNormalReplay()
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long transactionID = ++this.transactionID;
    FlumeEventPointer eventPointer = log.put(transactionID, eventIn);
    log.commitPut(transactionID); // this is not required since
    log.close();
    log = new Log.Builder().setCheckpointInterval(1L)
                           .setMaxFileSize(MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .build();
    doTestReplaySucceedsWithUnusedEmptyLogMetaData(eventIn, eventPointer);
  }

  @Test
  public void testReplaySucceedsWithUnusedEmptyLogMetaDataFastReplay()
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    long transactionID = ++this.transactionID;
    FlumeEventPointer eventPointer = log.put(transactionID, eventIn);
    log.commitPut(transactionID); // this is not required since
    log.close();
    checkpointDir = Files.createTempDir();
    FileUtils.forceDeleteOnExit(checkpointDir);
    Assert.assertTrue(checkpointDir.isDirectory());
    log = new Log.Builder().setCheckpointInterval(1L)
                           .setMaxFileSize(MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setChannelName("testlog")
                           .setUseFastReplay(true)
                           .build();
    doTestReplaySucceedsWithUnusedEmptyLogMetaData(eventIn, eventPointer);
  }

  public void doTestReplaySucceedsWithUnusedEmptyLogMetaData(FlumeEvent eventIn,
                                                             FlumeEventPointer eventPointer)
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    for (int i = 0; i < dataDirs.length; i++) {
      for (File logFile : LogUtils.getLogs(dataDirs[i])) {
        if (logFile.length() == 0L) {
          File logFileMeta = Serialization.getMetaDataFile(logFile);
          Assert.assertTrue(logFileMeta.delete());
          Assert.assertTrue(logFileMeta.createNewFile());
        }
      }
    }
    log.replay();
    FlumeEvent eventOut = log.get(eventPointer);
    Assert.assertNotNull(eventOut);
    Assert.assertEquals(eventIn.getHeaders(), eventOut.getHeaders());
    Assert.assertArrayEquals(eventIn.getBody(), eventOut.getBody());
  }

  @Test
  public void testCachedFSUsableSpace() throws Exception {
    File fs = mock(File.class);
    when(fs.getUsableSpace()).thenReturn(Long.MAX_VALUE);
    LogFile.CachedFSUsableSpace cachedFS = new LogFile.CachedFSUsableSpace(fs, 1000L);
    Assert.assertEquals(cachedFS.getUsableSpace(), Long.MAX_VALUE);
    cachedFS.decrement(Integer.MAX_VALUE);
    Assert.assertEquals(cachedFS.getUsableSpace(), Long.MAX_VALUE - Integer.MAX_VALUE);
    try {
      cachedFS.decrement(-1);
      Assert.fail();
    } catch (IllegalArgumentException expected) {

    }
    when(fs.getUsableSpace()).thenReturn(Long.MAX_VALUE - 1L);
    Thread.sleep(1100);
    Assert.assertEquals(cachedFS.getUsableSpace(), Long.MAX_VALUE - 1L);
  }

  @Test
  public void testCheckpointOnClose() throws Exception {
    log.close();
    log = new Log.Builder().setCheckpointInterval(1L)
                           .setMaxFileSize(MAX_FILE_SIZE)
                           .setQueueSize(CAPACITY)
                           .setCheckpointDir(checkpointDir)
                           .setLogDirs(dataDirs)
                           .setCheckpointOnClose(true)
                           .setChannelName("testLog")
                           .build();
    log.replay();

    // 1 Write One Event
    FlumeEvent eventIn = TestUtils.newPersistableEvent();
    log.put(transactionID, eventIn);
    log.commitPut(transactionID);

    // 2 Check state of checkpoint before close
    File checkPointMetaFile =
        FileUtils.listFiles(checkpointDir, new String[] { "meta" }, false).iterator().next();
    long before = FileUtils.checksumCRC32(checkPointMetaFile);

    // 3 Close Log
    log.close();

    // 4 Verify that checkpoint was modified on close
    long after = FileUtils.checksumCRC32(checkPointMetaFile);
    Assert.assertFalse(before == after);
  }

  private void takeAndVerify(FlumeEventPointer eventPointerIn, FlumeEvent eventIn)
      throws IOException, InterruptedException, NoopRecordException, CorruptEventException {
    FlumeEventQueue queue = log.getFlumeEventQueue();
    FlumeEventPointer eventPointerOut = queue.removeHead(0);
    Assert.assertNotNull(eventPointerOut);
    Assert.assertNull(queue.removeHead(0));
    Assert.assertEquals(eventPointerIn, eventPointerOut);
    Assert.assertEquals(eventPointerIn.hashCode(), eventPointerOut.hashCode());
    FlumeEvent eventOut = log.get(eventPointerOut);
    Assert.assertNotNull(eventOut);
    Assert.assertEquals(eventIn.getHeaders(), eventOut.getHeaders());
    Assert.assertArrayEquals(eventIn.getBody(), eventOut.getBody());
  }

}
