/*-
 * See the file LICENSE for redistribution information.
 *
 * Copyright (c) 2000-2003
 *      Sleepycat Software.  All rights reserved.
 *
 * $Id: StoredClassCatalog.java,v 1.10 2003/10/22 02:51:13 mhayes Exp $
 */

package com.sleepycat.bdb;

import com.sleepycat.bdb.bind.serial.ClassCatalog;
import com.sleepycat.bdb.util.IOExceptionWrapper;
import com.sleepycat.bdb.util.UtfOps;
import com.sleepycat.db.Db;
import com.sleepycat.db.Dbc;
import com.sleepycat.db.DbEnv;
import com.sleepycat.db.DbException;
import com.sleepycat.db.DbTxn;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.ObjectStreamClass;
import java.io.Serializable;
import java.math.BigInteger;
import java.util.HashMap;

/**
 * Java serialization catalog used for compact storage of database objects.
 *
 * @author Mark Hayes
 */
public class StoredClassCatalog implements ClassCatalog {

    private static final byte REC_LAST_CLASS_ID = (byte) 0;
    private static final byte REC_CLASS_FORMAT = (byte) 1;
    private static final byte REC_CLASS_INFO = (byte) 2;

    private static final byte[] LAST_CLASS_ID_KEY = {REC_LAST_CLASS_ID};

    /*
    Record types ([key] [data]):

    [0] [next class ID]
    [1 / class ID] [ObjectStreamClass (class format)]
    [2 / class name] [ClassInfo (has 8 byte class ID)]
    */

    private DataEnvironment env;
    private DataDb db;
    private HashMap classMap;
    private HashMap formatMap;

    /**
     * Open a catalog database. To save resources, only a single catalog object
     * should be used for each unique catalog file.
     *
     * @param env is the environment in which to open the catalog.
     *
     * @param file is the name of the catalog file.
     *
     * @param database the database name to be used within the specified
     * store.  If null then the filename is the database name.
     *
     * @param openFlags Flags for calling Db.open such as Db.DB_CREATE and
     * Db.DB_AUTO_COMMIT.
     */
    public StoredClassCatalog(DbEnv env, String file,
			      String database, int openFlags)
        throws FileNotFoundException, DbException {

        this.env = DataEnvironment.getEnvironment(env);

        DbTxn txn = this.env.getTxn();
        if (txn != null)
            openFlags &= ~Db.DB_AUTO_COMMIT;

        // open the catalog database

        Db db = new Db(env, 0);
        db.open(txn, file, database, Db.DB_BTREE, openFlags, 0);
        this.db = new DataDb(db);

        // create the class format and class info maps; note that these are not
        // synchronized, and therefore the methods that use them are
        // synchronized

        classMap = new HashMap();
        formatMap = new HashMap();
    }

    // javadoc is inherited
    public synchronized void close()
        throws IOException {

        try {
            if (db != null) {
                db.close();
                db = null;
            }
        } catch (DbException e) {
            throw new IOExceptionWrapper(e);
        }
        db = null;
        formatMap = null;
        classMap = null;
    }

    // javadoc is inherited
    public synchronized byte[] getClassID(String className)
        throws IOException, ClassNotFoundException {

        try {
            ClassInfo classInfo = getClassInfo(className);
            return classInfo.getClassID();
        } catch (DbException e) {
            throw new IOExceptionWrapper(e);
        }
    }

    // javadoc is inherited
    public synchronized ObjectStreamClass getClassFormat(String className)
        throws IOException, ClassNotFoundException {

        try {
            ClassInfo classInfo = getClassInfo(className);
            return classInfo.getClassFormat();
        } catch (DbException e) {
            throw new IOExceptionWrapper(e);
        }
    }

    // javadoc is inherited
    public ObjectStreamClass getClassFormat(byte[] classID)
        throws IOException, ClassNotFoundException {

        try {
            return getClassFormat(classID, newDbt());
        } catch (DbException e) {
            throw new IOExceptionWrapper(e);
        }
    }

    /**
     * Internal function for getting the class format.  Allows passing the Dbt
     * object for the data, so the bytes of the class format can be examined
     * afterwards.
     */
    private synchronized ObjectStreamClass getClassFormat(byte[] classID,
                                                          DataThang data)
        throws DbException, ClassNotFoundException, IOException {

        // first check the map and, if found, add class info to the map

        BigInteger classIDObj = new BigInteger(classID);
        ObjectStreamClass classFormat =
            (ObjectStreamClass) formatMap.get(classIDObj);
        if (classFormat == null) {

            // make the class format key

            byte[] keyBytes = new byte[classID.length + 1];
            keyBytes[0] = REC_CLASS_FORMAT;
            System.arraycopy(classID, 0, keyBytes, 1, classID.length);
            DataThang key = newDbt();
            key.setBytes(keyBytes);

            // read the class format

            int err = db.get(key, data, 0);
            if (err != 0) {
                throw new ClassNotFoundException("Catalog class ID not found");
            }
            ObjectInputStream ois =
                new ObjectInputStream(data.getByteStream());
            classFormat = (ObjectStreamClass) ois.readObject();

            // update the class format map

            formatMap.put(classIDObj, classFormat);
        }
        return classFormat;
    }

    /**
     * Get the ClassInfo for a given class name, adding it and its
     * ObjectStreamClass to the database if they are not already present, and
     * caching both of them using the class info and class format maps.  When a
     * class is first loaded from the database, the stored ObjectStreamClass is
     * compared to the current ObjectStreamClass loaded by the Java class
     * loader; if they are different, a new class ID is assigned for the
     * current format.
     */
    private ClassInfo getClassInfo(String className)
        throws IOException, ClassNotFoundException, DbException, DbException {

        // first check for a cached copy of the class info, which if
        // present always contains the class format object

        ClassInfo classInfo = (ClassInfo) classMap.get(className);
        if (classInfo != null) {
            return classInfo;
        } else {
            // get currently loaded class format

            Class cls = Class.forName(className);
            ObjectStreamClass classFormat = ObjectStreamClass.lookup(cls);

            // make class info key

            char[] nameChars = className.toCharArray();
            byte[] keyBytes = new byte[1 + UtfOps.getByteLength(nameChars)];
            keyBytes[0] = REC_CLASS_INFO;
            UtfOps.charsToBytes(nameChars, 0, keyBytes, 1, nameChars.length);
            DataThang key = newDbt();
            key.setBytes(keyBytes);

            // read class info

            DataThang data = newDbt();
            int err = db.get(key, data, 0);
            if (err != 0) {
                // not found in the database; write class info and class format

                classInfo = putClassInfo(new ClassInfo(), className, key,
                                         classFormat);
            } else {
                // read class info to get the class format key, then read class
                // format

                classInfo = new ClassInfo(data);
                DataThang formatData = newDbt();
                ObjectStreamClass storedClassFormat =
                    getClassFormat(classInfo.getClassID(), formatData);

                // compare the stored class format to the current class format,
                // and if they are different then generate a new class ID

                if (!areClassFormatsEqual(storedClassFormat,
                                          formatData.getBytes(),
                                          classFormat)) {
                    classInfo = putClassInfo(classInfo, className, key,
                                             classFormat);
                }

                // update the class info map

                classInfo.setClassFormat(classFormat);
                classMap.put(className, classInfo);
            }
        }
        return classInfo;
    }

    /**
     * Assign a new class ID (increment the current ID record), write the
     * ObjectStreamClass record for this new ID, and update the ClassInfo
     * record with the new ID also.  The ClassInfo passed as an argument is the
     * one to be updated; however, a different ClassInfo may be returned if
     * another process happens to update the catalog database before we do
     * (this is a rare concurrency issue).
     */
    private ClassInfo putClassInfo(ClassInfo classInfo, String className,
                                   DataThang classKey,
                                   ObjectStreamClass classFormat)
        throws DbException, ClassNotFoundException {

        // an intent-to-write cursor is needed for CDB

        Dbc cursor = db.openCursor(true);
        try {
            // get and lock the record containing the last assigned ID

            DataThang key = newDbt();
            key.setBytes(LAST_CLASS_ID_KEY);
            DataThang data = newDbt();
            int putFlag = Db.DB_CURRENT;
            int err = cursor.get(key, data,
                                 Db.DB_SET | env.getWriteLockFlag());
            if (err != 0) {
                // if this is a new database, set the initial ID record
                data.setBytes(new byte[1]); // zero ID
                putFlag = Db.DB_KEYLAST;
            }
            byte[] idBytes = data.getBytes();

            // check one last time to see if another thread
            // wrote the information before this thread

            Object anotherClassInfo = classMap.get(className);
            if (anotherClassInfo != null)
                return (ClassInfo) anotherClassInfo;

            // increment the ID by one and write the updated record

            idBytes = incrementID(idBytes);
            data.setBytes(idBytes);
            cursor.put(key, data, putFlag);

            // write the new class format record whose key is the ID just
            // assigned

            byte[] keyBytes = new byte[1 + idBytes.length];
            keyBytes[0] = REC_CLASS_FORMAT;
            System.arraycopy(idBytes, 0, keyBytes, 1, idBytes.length);
            key.setBytes(keyBytes);

            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            ObjectOutputStream oos;
            try {
                oos = new ObjectOutputStream(baos);
                oos.writeObject(classFormat);
            }
            catch (IOException e) {}
            data.setBytes(baos.toByteArray());

            db.put(key, data, 0);

            // write the new class info record, using the key passed in; this
            // is done last so that a reader who gets the class info record
            // first will always find the corresponding class format record

            classInfo.setClassID(idBytes);
            classInfo.toDbt(data);

            db.put(classKey, data, 0);

            // update the maps before closing the cursor, so that the cursor
            // lock prevents other writers from duplicating this entry

            classInfo.setClassFormat(classFormat);
            classMap.put(className, classInfo);
            formatMap.put(new BigInteger(idBytes), classFormat);
            return classInfo;
        } finally {
            db.closeCursor(cursor);
        }
    }

    private static byte[] incrementID(byte[] key) {

        BigInteger id = new BigInteger(key);
        id = id.add(BigInteger.valueOf(1));
        return id.toByteArray();
    }

    /**
     * Holds the class format key for a class, maintains a reference to the
     * ObjectStreamClass.  Other fields can be added when we need to store more
     * information per class.
     */
    private static class ClassInfo implements Serializable {

        private byte[] classID;
        private transient ObjectStreamClass classFormat;

        ClassInfo() {
        }

        ClassInfo(DataThang dbt) {

            byte[] data = dbt.getDataBytes();
            int len = data[0];
            classID = new byte[len];
            System.arraycopy(data, 1, classID, 0, len);
        }

        void toDbt(DataThang dbt) {

            byte[] data = new byte[1 + classID.length];
            data[0] = (byte) classID.length;
            System.arraycopy(classID, 0, data, 1, classID.length);
            dbt.setData(data, 0, data.length);
        }

        void setClassID(byte[] classID) {

            this.classID = classID;
        }

        byte[] getClassID() {

            return classID;
        }

        ObjectStreamClass getClassFormat() {

            return classFormat;
        }

        void setClassFormat(ObjectStreamClass classFormat) {

            this.classFormat = classFormat;
        }
    }

    /**
     * Return whether two class formats are equal.  This determines whether a
     * new class format is needed for an object being serialized.  Formats must
     * be identical in all respects, or a new format is needed.
     */
    private static boolean areClassFormatsEqual(ObjectStreamClass format1,
                                                byte[] format1Bytes,
                                                ObjectStreamClass format2) {
        try {
            if (format1Bytes == null) { // using cached format1 object
                format1Bytes = getObjectBytes(format1);
            }
            byte[] format2Bytes = getObjectBytes(format2);
            return java.util.Arrays.equals(format2Bytes, format1Bytes);
        } catch (IOException e) { return false; }
    }

    private static byte[] getObjectBytes(Object o)
        throws IOException {

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(o);
        return baos.toByteArray();
    }

    private DataThang newDbt() {

        return new DataThang();
    }
}
