/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at legal-notices/CDDLv1_0.txt
 * or http://forgerock.org/license/CDDLv1.0.html.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at legal-notices/CDDLv1_0.txt.
 * If applicable, add the following below this CDDL HEADER, with the
 * fields enclosed by brackets "[]" replaced with your own identifying
 * information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2006-2010 Sun Microsystems, Inc.
 *      Portions Copyright 2012-2015 ForgeRock AS.
 */
package org.opends.server.backends.pluggable;

import static org.forgerock.util.Reject.*;
import static org.forgerock.util.Utils.*;
import static org.opends.messages.BackendMessages.*;
import static org.opends.server.core.DirectoryServer.*;
import static org.opends.server.backends.pluggable.CursorTransformer.transformKeysAndValues;

import java.io.IOException;
import java.io.OutputStream;
import java.util.zip.DataFormatException;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterOutputStream;

import org.forgerock.i18n.slf4j.LocalizedLogger;
import org.forgerock.opendj.io.ASN1;
import org.forgerock.opendj.io.ASN1Reader;
import org.forgerock.opendj.io.ASN1Writer;
import org.forgerock.opendj.ldap.ByteSequence;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ByteStringBuilder;
import org.forgerock.opendj.ldap.DecodeException;
import org.forgerock.util.Function;
import org.forgerock.util.Reject;
import org.opends.server.api.CompressedSchema;
import org.opends.server.backends.pluggable.spi.Cursor;
import org.opends.server.backends.pluggable.spi.ReadableTransaction;
import org.opends.server.backends.pluggable.spi.StorageRuntimeException;
import org.opends.server.backends.pluggable.spi.TreeName;
import org.opends.server.backends.pluggable.spi.WriteableTransaction;
import org.opends.server.core.DirectoryServer;
import org.opends.server.types.DirectoryException;
import org.opends.server.types.Entry;
import org.opends.server.types.LDAPException;

/**
 * Represents the tree containing the LDAP entries.
 * The key is the entry ID and the value is the entry contents.
 */
class ID2Entry extends AbstractTree
{
  private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();

  /** Transforms cursor keys into EntryIDs. */
  private static final Function<ByteString, EntryID, Exception> TO_ENTRY_ID =
          new Function<ByteString, EntryID, Exception>() {
    @Override
    public EntryID apply(ByteString value) throws Exception {
      return new EntryID(value);
    }
  };

  /** Transforms cursor values into Entry objects. */
  private final CursorTransformer.ValueTransformer<ByteString, ByteString, Entry, Exception> TO_ENTRY =
          new CursorTransformer.ValueTransformer<ByteString, ByteString, Entry, Exception>() {
    @Override
    public Entry transform(ByteString key, ByteString value) throws Exception {
      return get0(value);
    }
  };

  /** Parameters for compression and encryption. */
  private DataConfig dataConfig;

  /** Cached encoding buffers. */
  private static final ThreadLocal<EntryCodec> ENTRY_CODEC_CACHE = new ThreadLocal<EntryCodec>()
  {
    @Override
    protected EntryCodec initialValue()
    {
      return new EntryCodec();
    }
  };

  private static EntryCodec acquireEntryCodec()
  {
    EntryCodec codec = ENTRY_CODEC_CACHE.get();
    if (codec.maxBufferSize != getMaxInternalBufferSize())
    {
      // Setting has changed, so recreate the codec.
      codec = new EntryCodec();
      ENTRY_CODEC_CACHE.set(codec);
    }
    return codec;
  }

  /** A cached set of ByteStringBuilder buffers and ASN1Writer used to encode entries. */
  private static final class EntryCodec
  {
    /** The ASN1 tag for the ByteString type. */
    private static final byte TAG_TREE_ENTRY = 0x60;
    private static final int BUFFER_INIT_SIZE = 512;

    private final ByteStringBuilder encodedBuffer = new ByteStringBuilder();
    private final ByteStringBuilder entryBuffer = new ByteStringBuilder();
    private final ByteStringBuilder compressedEntryBuffer = new ByteStringBuilder();
    private final ASN1Writer writer;
    private final int maxBufferSize;

    private EntryCodec()
    {
      this.maxBufferSize = getMaxInternalBufferSize();
      this.writer = ASN1.getWriter(encodedBuffer, maxBufferSize);
    }

    private void release()
    {
      closeSilently(writer);
      encodedBuffer.clearAndTruncate(maxBufferSize, BUFFER_INIT_SIZE);
      entryBuffer.clearAndTruncate(maxBufferSize, BUFFER_INIT_SIZE);
      compressedEntryBuffer.clearAndTruncate(maxBufferSize, BUFFER_INIT_SIZE);
    }

    private Entry decode(ByteString bytes, CompressedSchema compressedSchema)
        throws DirectoryException, DecodeException, IOException
    {
      // Get the format version.
      byte formatVersion = bytes.byteAt(0);
      if(formatVersion != DnKeyFormat.FORMAT_VERSION)
      {
        throw DecodeException.error(ERR_INCOMPATIBLE_ENTRY_VERSION.get(formatVersion));
      }

      // Read the ASN1 sequence.
      ASN1Reader reader = ASN1.getReader(bytes.subSequence(1, bytes.length()));
      reader.readStartSequence();

      // See if it was compressed.
      int uncompressedSize = (int)reader.readInteger();
      if(uncompressedSize > 0)
      {
        // It was compressed.
        reader.readOctetString(compressedEntryBuffer);

        OutputStream decompressor = null;
        try
        {
          // TODO: Should handle the case where uncompress fails
          decompressor = new InflaterOutputStream(entryBuffer.asOutputStream());
          compressedEntryBuffer.copyTo(decompressor);
        }
        finally {
          closeSilently(decompressor);
        }

        // Since we are used the cached buffers (ByteStringBuilders),
        // the decoded attribute values will not refer back to the
        // original buffer.
        return Entry.decode(entryBuffer.asReader(), compressedSchema);
      }
      else
      {
        // Since we don't have to do any decompression, we can just decode
        // the entry directly.
        ByteString encodedEntry = reader.readOctetString();
        return Entry.decode(encodedEntry.asReader(), compressedSchema);
      }
    }

    private ByteString encode(Entry entry, DataConfig dataConfig) throws DirectoryException
    {
      encodeVolatile(entry, dataConfig);
      return encodedBuffer.toByteString();
    }

    private void encodeVolatile(Entry entry, DataConfig dataConfig) throws DirectoryException
    {
      // Encode the entry for later use.
      entry.encode(entryBuffer, dataConfig.getEntryEncodeConfig());

      // First write the DB format version byte.
      encodedBuffer.appendByte(DnKeyFormat.FORMAT_VERSION);

      try
      {
        // Then start the ASN1 sequence.
        writer.writeStartSequence(TAG_TREE_ENTRY);

        if (dataConfig.isCompressed())
        {
          OutputStream compressor = null;
          try {
            compressor = new DeflaterOutputStream(compressedEntryBuffer.asOutputStream());
            entryBuffer.copyTo(compressor);
          }
          finally {
            closeSilently(compressor);
          }

          // Compression needed and successful.
          writer.writeInteger(entryBuffer.length());
          writer.writeOctetString(compressedEntryBuffer);
        }
        else
        {
          writer.writeInteger(0);
          writer.writeOctetString(entryBuffer);
        }

        writer.writeEndSequence();
      }
      catch(IOException ioe)
      {
        // TODO: This should never happen with byte buffer.
        logger.traceException(ioe);
      }
    }
  }

  /**
   * Create a new ID2Entry object.
   *
   * @param name The name of the entry tree.
   * @param dataConfig The desired compression and encryption options for data
   * stored in the entry tree.
   * @throws StorageRuntimeException If an error occurs in the storage.
   */
  ID2Entry(TreeName name, DataConfig dataConfig) throws StorageRuntimeException
  {
    super(name);
    this.dataConfig = dataConfig;
  }

  @Override
  void afterOpen(WriteableTransaction txn, boolean createOnDemand) throws StorageRuntimeException
  {
    // Make sure the tree is there and readable, even if the storage is READ_ONLY.
    // Would be nice if there were a better way...
    try (final Cursor<ByteString, ByteString> cursor = txn.openCursor(getName()))
    {
      cursor.next();
    }
  }

  /**
   * Decodes an entry from its tree representation.
   * <p>
   * An entry on disk is ASN1 encoded in this format:
   *
   * <pre>
   * ByteString ::= [APPLICATION 0] IMPLICIT SEQUENCE {
   *  uncompressedSize      INTEGER,      -- A zero value means not compressed.
   *  dataBytes             OCTET STRING  -- Optionally compressed encoding of
   *                                         the data bytes.
   * }
   *
   * ID2EntryValue ::= ByteString
   *  -- Where dataBytes contains an encoding of DirectoryServerEntry.
   *
   * DirectoryServerEntry ::= [APPLICATION 1] IMPLICIT SEQUENCE {
   *  dn                      LDAPDN,
   *  objectClasses           SET OF LDAPString,
   *  userAttributes          AttributeList,
   *  operationalAttributes   AttributeList
   * }
   * </pre>
   *
   * @param bytes A byte array containing the encoded tree value.
   * @param compressedSchema The compressed schema manager to use when decoding.
   * @return The decoded entry.
   * @throws DecodeException If the data is not in the expected ASN.1 encoding
   * format.
   * @throws LDAPException If the data is not in the expected ASN.1 encoding
   * format.
   * @throws DataFormatException If an error occurs while trying to decompress
   * compressed data.
   * @throws DirectoryException If a Directory Server error occurs.
   * @throws IOException if an error occurs while reading the ASN1 sequence.
   */
  static Entry entryFromDatabase(ByteString bytes,
      CompressedSchema compressedSchema) throws DirectoryException,
      DecodeException, LDAPException, DataFormatException, IOException
  {
    EntryCodec codec = acquireEntryCodec();
    try
    {
      return codec.decode(bytes, compressedSchema);
    }
    finally
    {
      codec.release();
    }
  }

  /**
   * Encodes an entry to the raw tree format, with optional compression.
   *
   * @param entry The entry to encode.
   * @param dataConfig Compression and cryptographic options.
   * @return A ByteSTring containing the encoded tree value.
   *
   * @throws  DirectoryException  If a problem occurs while attempting to encode
   *                              the entry.
   */
  static ByteString entryToDatabase(Entry entry, DataConfig dataConfig) throws DirectoryException
  {
    EntryCodec codec = acquireEntryCodec();
    try
    {
      return codec.encode(entry, dataConfig);
    }
    finally
    {
      codec.release();
    }
  }

  ByteString encode(Entry entry) throws DirectoryException {
    return entryToDatabase(entry, dataConfig);
  }

  /**
   * Write a record in the entry tree.
   *
   * @param txn a non null transaction
   * @param entryID The entry ID which forms the key.
   * @param entry The LDAP entry.
   * @throws StorageRuntimeException If an error occurs in the storage.
   * @throws  DirectoryException  If a problem occurs while attempting to encode
   *                              the entry.
   */
  public void put(WriteableTransaction txn, EntryID entryID, Entry entry)
       throws StorageRuntimeException, DirectoryException
  {
    put(txn, entryID, encode(entry));
  }

  public void put(WriteableTransaction txn, EntryID entryID, ByteSequence encodedEntry)
      throws StorageRuntimeException, DirectoryException
  {
    Reject.ifNull(txn, "txn must not be null.");
    txn.put(getName(), entryID.toByteString(), encodedEntry);
  }

  /**
   * Remove a record from the entry tree.
   *
   * @param txn a non null transaction
   * @param entryID The entry ID which forms the key.
   * @return true if the entry was removed, false if it was not.
   * @throws StorageRuntimeException If an error occurs in the storage.
   */
  boolean remove(WriteableTransaction txn, EntryID entryID) throws StorageRuntimeException
  {
    return txn.delete(getName(), entryID.toByteString());
  }

  /**
   * Fetch a record from the entry tree.
   *
   * @param txn a non null transaction
   * @param entryID The desired entry ID which forms the key.
   * @return The requested entry, or null if there is no such record.
   * @throws DirectoryException If a problem occurs while getting the entry.
   * @throws StorageRuntimeException If an error occurs in the storage.
   */
  public Entry get(ReadableTransaction txn, EntryID entryID)
       throws DirectoryException, StorageRuntimeException
  {
    try
    {
      return get0(txn.read(getName(), entryID.toByteString()));
    }
    catch (Exception e)
    {
      throw new DirectoryException(DirectoryServer.getServerErrorResultCode(), ERR_ENTRY_DATABASE_CORRUPT.get(entryID));
    }
  }

  Cursor<EntryID, Entry> openCursor(ReadableTransaction txn)
  {
    return transformKeysAndValues(txn.openCursor(getName()), TO_ENTRY_ID, TO_ENTRY);
  }

  /**
   * Check that a record entry exists in the entry tree.
   *
   * @param txn a non null transaction
   * @param entryID The entry ID which forms the key.
   * @return True if an entry with entryID exists
   * @throws StorageRuntimeException If an error occurs in the storage.
   */
  public boolean containsEntryID(ReadableTransaction txn, EntryID entryID)
  {
    checkNotNull(txn, "txn must not be null");
    checkNotNull(entryID, "entryID must not be null");
    try(final Cursor<ByteString, ByteString> cursor = txn.openCursor(getName())) {
      return cursor.positionToKey(entryID.toByteString());
    }
  }

  private Entry get0(ByteString value) throws Exception
  {
    if (value == null)
    {
      return null;
    }
    final Entry entry = entryFromDatabase(value, dataConfig.getEntryEncodeConfig().getCompressedSchema());
    entry.processVirtualAttributes();
    return entry;
  }

  /**
   * Set the desired compression and encryption options for data
   * stored in the entry tree.
   *
   * @param dataConfig The desired compression and encryption options for data
   * stored in the entry tree.
   */
  void setDataConfig(DataConfig dataConfig)
  {
    this.dataConfig = dataConfig;
  }

  @Override
  public String keyToString(ByteString key)
  {
    return new EntryID(key).toString();
  }

  @Override
  public String valueToString(ByteString value)
  {
    try
    {
      return "\n" + get0(value).toString();
    }
    catch (Exception e)
    {
      return e.getMessage();
    }
  }

  @Override
  public ByteString generateKey(String data)
  {
    EntryID entryID = new EntryID(Long.parseLong(data));
    return entryID.toByteString();
  }
}
