/*-
 * #%L
 * LmdbJava
 * %%
 * Copyright (C) 2016 - 2020 The LmdbJava Open Source Project
 * %%
 * Licensed 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.
 * #L%
 */

package org.lmdbjava;

import static java.nio.ByteOrder.BIG_ENDIAN;
import static java.util.Objects.requireNonNull;
import static org.lmdbjava.DbiFlags.MDB_CREATE;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.CRC32;

/**
 * Verifies correct operation of LmdbJava in a given environment.
 *
 * <p>
 * Due to the large variety of operating systems and Java platforms typically
 * used with LmdbJava, this class provides a convenient verification of correct
 * operating behavior through a potentially long duration set of tests that
 * carefully verify correct storage and retrieval of successively larger
 * database entries.
 *
 * <p>
 * The verifier currently operates by incrementing a <code>long</code>
 * identifier that deterministically maps to a given {@link Dbi} and value size.
 * The key is simply the <code>long</code> identifier. The value commences with
 * a CRC that includes the identifier and the random bytes of the value. Each
 * entry is written out, and then the prior entry is retrieved using its key.
 * The prior entry's value is evaluated for accuracy and then deleted.
 * Transactions are committed in batches to ensure successive transactions
 * correctly retrieve the results of earlier transactions.
 *
 * <p>
 * Please note the verification approach may be modified in the future.
 *
 * <p>
 * If an exception is raised by this class, please:
 *
 * <ol>
 * <li>Ensure the {@link Env} passed at construction time complies with the
 * requirements specified at {@link #Verifier(org.lmdbjava.Env)}</li>
 * <li>Attempt to use a different file system to store the database (be
 * especially careful to not use network file systems, remote file systems,
 * read-only file systems etc)</li>
 * <li>Record the full exception message and stack trace, then run the verifier
 * again to see if it fails at the same or a different point</li>
 * <li>Raise a ticket on the LmdbJava Issue Tracker that confirms the above
 * details along with the failing operating system and Java version</li>
 * </ol>
 *
 */
public final class Verifier implements Callable<Long> {

  /**
   * Number of DBIs the created environment should allow.
   */
  public static final int DBI_COUNT = 5;
  private static final int BATCH_SIZE = 64;
  private static final int BUFFER_LEN = 1_024 * BATCH_SIZE;
  private static final int CRC_LENGTH = Long.BYTES;
  private static final int KEY_LENGTH = Long.BYTES;
  private final byte[] ba = new byte[BUFFER_LEN];
  private final CRC32 crc = new CRC32();
  private final List<Dbi<ByteBuffer>> dbis = new ArrayList<>(DBI_COUNT);
  private final Env<ByteBuffer> env;
  private long id;
  private final ByteBuffer key = ByteBuffer.allocateDirect(KEY_LENGTH);
  private final AtomicBoolean proceed = new AtomicBoolean(true);
  private final Random rnd = new Random();
  private Txn<ByteBuffer> txn;
  private final ByteBuffer val = ByteBuffer.allocateDirect(BUFFER_LEN);

  /**
   * Create an instance of the verifier.
   *
   * <p>
   * The caller must provide an {@link Env} configured with a suitable local
   * storage location, maximum DBIs equal to {@link #DBI_COUNT}, and a
   * map size large enough to accommodate the intended verification duration.
   *
   * <p>
   * ALL EXISTING DATA IN THE DATABASE WILL BE DELETED. The caller must not
   * interact with the <code>Env</code> in any way (eg querying, transactions
   * etc) while the verifier is executing.
   *
   * @param env target that complies with the above requirements (required)
   */
  public Verifier(final Env<ByteBuffer> env) {
    requireNonNull(env);
    this.env = env;
    key.order(BIG_ENDIAN);

    deleteDbis();
    createDbis();
  }

  /**
   * Run the verifier until {@link #stop()} is called or an exception occurs.
   *
   * <p>
   * Successful return of this method indicates no faults were detected. If any
   * fault was detected the exception message will detail the exact point that
   * the fault was encountered.
   *
   * @return number of database rows successfully verified
   */
  @Override
  public Long call() {
    try {
      while (proceed.get()) {
        transactionControl();

        write(id);

        if (id > 0) {
          fetchAndDelete(id - 1);
        }

        id++;
      }
    } finally {
      if (txn != null) {
        txn.close();
      }
    }
    return id;
  }

  /**
   * Execute the verifier for the given duration.
   *
   * <p>
   * This provides a simple way to execute the verifier for those applications
   * which do not wish to manage threads directly.
   *
   * @param duration amount of time to execute
   * @param unit     units used to express the duration
   * @return number of database rows successfully verified
   */
  public long runFor(final long duration, final TimeUnit unit) {
    final long deadline = System.currentTimeMillis() + unit.toMillis(duration);
    final ExecutorService es = Executors.newSingleThreadExecutor();
    final Future<Long> future = es.submit(this);
    try {
      while (System.currentTimeMillis() < deadline && !future.isDone()) {
        Thread.sleep(unit.toMillis(1));
      }
    } catch (final InterruptedException ignored) {
    } finally {
      stop();
    }
    final long result;
    try {
      result = future.get();
    } catch (final InterruptedException | ExecutionException ex) {
      throw new IllegalStateException(ex);
    } finally {
      es.shutdown();
    }
    return result;
  }

  private void createDbis() {
    for (int i = 0; i < DBI_COUNT; i++) {
      dbis.add(env.openDbi(Verifier.class.getSimpleName() + i, MDB_CREATE));
    }
  }

  private void deleteDbis() {
    for (final byte[] existingDbiName : env.getDbiNames()) {
      final Dbi<ByteBuffer> existingDbi = env.openDbi(existingDbiName);
      try (Txn<ByteBuffer> txn = env.txnWrite()) {
        existingDbi.drop(txn, true);
        txn.commit();
      }
    }
  }

  private void fetchAndDelete(final long forId) {
    final Dbi<ByteBuffer> dbi = getDbi(forId);
    updateKey(forId);
    final ByteBuffer fetchedValue;
    try {
      fetchedValue = dbi.get(txn, key);
    } catch (final LmdbException ex) {
      throw new IllegalStateException("DB get id=" + forId, ex);
    }

    if (fetchedValue == null) {
      throw new IllegalStateException("DB not found id=" + forId);
    }

    verifyValue(forId, fetchedValue);

    try {
      dbi.delete(txn, key);
    } catch (final LmdbException ex) {
      throw new IllegalStateException("DB del id=" + forId, ex);
    }
  }

  private Dbi<ByteBuffer> getDbi(final long forId) {
    return dbis.get((int) (forId % dbis.size()));
  }

  /**
   * Request the verifier to stop execution.
   */
  private void stop() {
    proceed.set(false);
  }

  private void transactionControl() {
    if (id % BATCH_SIZE == 0) {
      if (txn != null) {
        txn.commit();
        txn.close();
      }
      rnd.nextBytes(ba);
      txn = env.txnWrite();
    }
  }

  private void updateKey(final long forId) {
    key.clear();
    key.putLong(forId);
    key.flip();
  }

  private void updateValue(final long forId) {
    final int rndSize = valueSize(forId);
    crc.reset();
    crc.update((int) forId);
    crc.update(ba, CRC_LENGTH, rndSize);
    final long crcVal = crc.getValue();

    val.clear();
    val.putLong(crcVal);
    val.put(ba, CRC_LENGTH, rndSize);
    val.flip();
  }

  private int valueSize(final long forId) {
    final int mod = (int) (forId % BATCH_SIZE);
    final int base = 1_024 * mod;
    final int value = base == 0 ? 512 : base;
    return value - CRC_LENGTH - KEY_LENGTH; // aim to minimise partial pages
  }

  private void verifyValue(final long forId, final ByteBuffer bb) {
    final int rndSize = valueSize(forId);
    final int expected = rndSize + CRC_LENGTH;
    if (bb.limit() != expected) {
      throw new IllegalStateException("Limit error id=" + forId + " exp="
                                          + expected + " limit=" + bb.limit());
    }

    final long crcRead = bb.getLong();
    crc.reset();
    crc.update((int) forId);
    crc.update(bb);
    final long crcVal = crc.getValue();

    if (crcRead != crcVal) {
      throw new IllegalStateException("CRC error id=" + forId);
    }
  }

  private void write(final long forId) {
    final Dbi<ByteBuffer> dbi = getDbi(forId);
    updateKey(forId);
    updateValue(forId);

    try {
      dbi.put(txn, key, val);
    } catch (final LmdbException ex) {
      throw new IllegalStateException("DB put id=" + forId, ex);
    }
  }

}