/*
 * JBoss, Home of Professional Open Source.
 * Copyright 2000 - 2008, Red Hat Middleware LLC, and individual contributors
 * as indicated by the @author tags. See the copyright.txt file in the
 * distribution for a full listing of individual contributors.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.jboss.cache.loader;

import net.jcip.annotations.ThreadSafe;
import org.apache.commons.logging.Log;
import org.jboss.cache.Fqn;
import org.jboss.cache.Modification;
import org.jboss.cache.config.CacheLoaderConfig;
import org.jboss.cache.io.ByteBuffer;
import org.jboss.cache.lock.StripedLock;
import org.jboss.cache.util.Util;

import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Types;
import java.util.AbstractMap;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

/**
 * Adjacency List Model is the model of persisting trees in which each children holds a reference to its parent.
 * An alternative model is the Nested Set Model (a.k.a. Modified Preorder Model) - this approach adds some additional
 * indexing information to each persisted node. This indexing info is further used for optimizing operations like
 * subtree loading, deleting etc. The indexes are update for each insertion.
 * <p/>
 * Adjacency List Model proved more performance-effective for the following reason: the entire path is persisted rather
 * than only a reference to parent. Looking up nodes heavily relies on that, and the performance is similar as in the
 * case of Modified Preorder Model. Even more there is no costly update indexes operation.
 *
 * @author Mircea.Markus@iquestint.com
 * @author <a href="mailto:galder.zamarreno@jboss.com">Galder Zamarreno</a>
 * @version 1.0
 */
@ThreadSafe
public abstract class AdjListJDBCCacheLoader extends AbstractCacheLoader
{
   protected ConnectionFactory cf;
   protected String driverName;
   private AdjListJDBCCacheLoaderConfig config;
   protected StripedLock lock = new StripedLock(128);
   // dummy, used for serializing empty maps.  One should NOT use Collections.emptyMap() here since if an emptyMap is
   // serialized, upon deserialization it cannot be added to.
   private static final Map<Object, Object> EMPTY_HASHMAP = new HashMap<Object, Object>(0, 1);

   /**
    * Creates a prepared statement using the given connection and SQL string, logs the statement that is about to be
    * executed to the logger, and optionally sets String parameters provided on the prepared statement before
    * returning the prepared statement.
    *
    * @param conn   Connection to use to create the prepared statement
    * @param sql    SQL to use with the prepared statement
    * @param params optional parameters to add to the statement.
    * @return a prepared statement
    * @throws Exception if there are problems
    */
   protected PreparedStatement prepareAndLogStatement(Connection conn, String sql, String... params) throws Exception
   {
      PreparedStatement ps = conn.prepareStatement(sql);
      for (int i = 0; i < params.length; i++) ps.setString(i + 1, params[i]);

      // Logging the SQL we plan to run
      if (getLogger().isTraceEnabled())
      {
         StringBuilder sb = new StringBuilder("Executing SQL statement [");
         sb.append(sql).append("]");
         if (params.length != 0)
         {
            sb.append(" with params ");
            boolean first = true;
            for (String param : params)
            {
               if (first)
               {
                  first = false;
               }
               else
               {
                  sb.append(", ");
               }
               sb.append("[").append(param).append("]");
            }
         }

         getLogger().trace(sb.toString());
      }
      return ps;
   }

   public void setConfig(CacheLoaderConfig.IndividualCacheLoaderConfig base)
   {
      config = processConfig(base);

      if (config.getDatasourceName() == null)
      {
         try
         {
            /* Instantiate an standalone connection factory as per configuration, either explicitly
       defined or the default one */
            getLogger().debug("Initialising with a connection factory since data source is not provided.");
            if (getLogger().isDebugEnabled())
            {
               getLogger().debug("Using connection factory " + config.getConnectionFactoryClass());
            }
            cf = (ConnectionFactory) Util.loadClass(config.getConnectionFactoryClass()).newInstance();
         }
         catch (Exception e)
         {
            getLogger().error("Connection factory class could not be loaded", e);
            throw new IllegalStateException("Connection factory class could not be loaded", e);
         }
      }
      else
      {
         /* We create the ManagedConnectionFactory instance but the JNDI lookup is no done until
the start method is called, since that's when its registered in its lifecycle */
         cf = new ManagedConnectionFactory();
      }
      /* Regardless of the type of connection factory, we set the configuration */
      cf.setConfig(config);
   }


   /**
    * Returns a map representing a node.
    *
    * @param name node's fqn
    * @return node
    * @throws Exception
    */
   public Map<Object, Object> get(Fqn name) throws Exception
   {
      lock.acquireLock(name, false);
      try
      {
         final Map<Object, Object> node = loadNode(name);
         return node == NULL_NODE_IN_ROW ? new HashMap<Object, Object>(0) : node;
      }
      finally
      {
         lock.releaseLock(name);
      }
   }

   /**
    * Fetches child node names (not pathes).
    *
    * @param fqn parent fqn
    * @return a set of child node names or null if there are not children found for the fqn
    * @throws Exception
    */
   public Set<String> getChildrenNames(Fqn fqn) throws Exception
   {
      Set<String> children = null;
      Connection con = null;
      PreparedStatement ps = null;
      ResultSet rs = null;
      try
      {
         lock.acquireLock(fqn, false);
         con = cf.getConnection();
         ps = prepareAndLogStatement(con, config.getSelectChildNamesSql(), fqn.toString());
         rs = ps.executeQuery();
         if (rs.next())
         {
            children = new HashSet<String>();
            do
            {
               String child = rs.getString(1);
               int slashInd = child.lastIndexOf('/');
               String name = child.substring(slashInd + 1);
               //Fqn childFqn = Fqn.fromString(child);
               //String name = (String) childFqn.get(childFqn.size() - 1);
               children.add(name);
            }
            while (rs.next());
         }
      }
      catch (SQLException e)
      {
         reportAndRethrowError("Failed to get children names for fqn " + fqn, e);
      }
      finally
      {
         safeClose(rs);
         safeClose(ps);
         cf.close(con);
         lock.releaseLock(fqn);
      }

      return children == null ? null : Collections.unmodifiableSet(children);
   }


   /**
    * Nullifies the node.
    *
    * @param name node's fqn
    * @throws Exception
    */
   public void removeData(Fqn name) throws Exception
   {
      updateNode(name, null);
   }

   /**
    * First phase in transaction commit process. The changes are committed if only one phase if requested.
    * All the modifications are committed using the same connection.
    *
    * @param tx            something representing transaction
    * @param modifications a list of modifications
    * @param one_phase     indicates whether it's one or two phase commit transaction
    * @throws Exception
    */
   @Override
   public void prepare(Object tx, List<Modification> modifications, boolean one_phase) throws Exception
   {
      // start a tx
      cf.prepare(tx);
      put(modifications);
      // commit if it's one phase only
      if (one_phase) commit(tx);
   }

   /**
    * Commits a transaction.
    *
    * @param tx the tx to commit
    * @throws Exception
    */
   @Override
   public void commit(Object tx) throws Exception
   {
      cf.commit(tx);
   }

   /**
    * Rolls back a transaction.
    *
    * @param tx the tx to rollback
    */
   @Override
   public void rollback(Object tx)
   {
      cf.rollback(tx);
   }

   // Service implementation

   @Override
   public void start() throws Exception
   {
      cf.start();

      Connection con = null;
      Statement st = null;

      try
      {
         con = cf.getConnection();
         driverName = getDriverName(con);
         if (config.getCreateTable() && !tableExists(config.getTable(), con))
         {
            if (getLogger().isDebugEnabled())
            {
               getLogger().debug("executing ddl: " + config.getCreateTableDDL());
            }
            st = con.createStatement();
            st.executeUpdate(config.getCreateTableDDL());
         }
      }
      finally
      {
         safeClose(st);
         cf.close(con);
      }

      if (config.getCreateTable())
      {
          createDummyTableIfNeeded();
      }
   }

   private void createDummyTableIfNeeded() throws Exception
   {
      Connection conn = null;
      PreparedStatement ps = null;
      
      if (config.getDropTable())
      {
          try
          {
              conn = cf.getConnection();
              ps = prepareAndLogStatement(conn, config.getDummyTableRemovalDDL());
              ps.execute();
          }
          catch (Exception e)
          {
              if (getLogger().isTraceEnabled())
                  getLogger().trace("No need to drop tables!");
          }
          finally
          {
              safeClose(ps);
              cf.close(conn);
          }
      }
      
      try
      {
         conn = cf.getConnection();
         if (!tableExists(config.getDummyTable(), conn))
         {
             ps = prepareAndLogStatement(conn, config.getDummyTableCreationDDL());
             ps.execute();
             safeClose(ps);
             ps = prepareAndLogStatement(conn, config.getDummyTablePopulationSql());
             ps.execute();
         }
      }
      finally
      {
         safeClose(ps);
         cf.close(conn);
      }
   }

   @Override
   public void stop()
   {
      try
      {
         if (config.getDropTable())
         {
            Connection con = null;
            Statement st = null;
            try
            {
               if (getLogger().isDebugEnabled())
               {
                  getLogger().debug("executing ddl: " + config.getDropTableDDL());
               }

               con = cf.getConnection();
               st = con.createStatement();
               st.executeUpdate(config.getDropTableDDL());
               safeClose(st);
            }
            catch (SQLException e)
            {
               getLogger().error("Failed to drop table: " + e.getMessage(), e);
            }
            finally
            {
               safeClose(st);
               cf.close(con);
            }
         }
      }
      finally
      {
         cf.stop();
      }
   }

   /**
    * Checks that there is a row for the fqn in the database.
    *
    * @param name node's fqn
    * @return true if there is a row in the database for the given fqn even if the node column is null.
    * @throws Exception
    */
   public boolean exists(Fqn name) throws Exception
   {
      if (getLogger().isTraceEnabled())
          getLogger().trace("exists name=" + name);
      lock.acquireLock(name, false);
      Connection conn = null;
      PreparedStatement ps = null;
      ResultSet rs = null;
      try
      {
         conn = cf.getConnection();
         ps = prepareAndLogStatement(conn, config.getExistsSql(), name.toString());
         rs = ps.executeQuery();
         boolean res = rs.next();
         if (getLogger().isTraceEnabled())
             getLogger().trace("exists name=" + name + " is " + res);
         return res;
      }
      finally
      {
         lock.releaseLock(name);
         safeClose(rs);
         safeClose(ps);
         cf.close(conn);
      }
   }

   /**
    * Removes attribute's value for a key. If after removal the node contains no attributes, the node is nullified.
    *
    * @param name node's name
    * @param key  attribute's key
    * @return removed value or null if there was no value for the passed in key
    * @throws Exception
    */
   public Object remove(Fqn name, Object key) throws Exception
   {
      if (getLogger().isTraceEnabled())
           getLogger().trace("remove name=" + name);
      lock.acquireLock(name, true);
      try
      {
         Object removedValue = null;
         Map<Object, Object> node = loadNode(name);
         if (node != null && node != NULL_NODE_IN_ROW)
         {
            removedValue = node.remove(key);
            if (node.isEmpty())
            {
               updateNode(name, null);
            }
            else
            {
               updateNode(name, node);
            }
         }
         return removedValue;
      }
      finally
      {
         lock.releaseLock(name);
      }
   }


   /**
    * Loads a node from the database.
    *
    * @param name the fqn
    * @return non-null Map representing the node,
    *         null if there is no row with the fqn in the table,
    *         NULL_NODE_IN_ROW if there is a row in the table with the fqn but the node column contains null.
    */
   @SuppressWarnings("unchecked")
   protected Map<Object, Object> loadNode(Fqn name)
   {
      if (getLogger().isTraceEnabled())
         getLogger().trace("loadNode name=" + name);
      boolean rowExists = false;
      Connection con = null;
      PreparedStatement ps = null;
      ResultSet rs = null;
      try
      {
         con = cf.getConnection();
         ps = prepareAndLogStatement(con, config.getSelectNodeSql(), name.toString());
         rs = ps.executeQuery();

         if (rs.next())
         {
            rowExists = true;
            InputStream is = rs.getBinaryStream(1);
            if (is != null && !rs.wasNull())
            {
               try
               {
                  // deserialize result
                  return (Map<Object, Object>) unmarshall(is);
               }
               catch (Exception e)
               {
                  throw new Exception("Unable to load to deserialize result: ", e);
               }
               finally
               {
                  safeClose(is);
               }
            }
         }
      }
      catch (Exception e)
      {
         reportAndRethrowError("Failed to load node for fqn " + name, e);
      }
      finally
      {
         safeClose(rs);
         safeClose(ps);
         cf.close(con);
      }

      return rowExists ? NULL_NODE_IN_ROW : null;
   }


   /**
    * Inserts a node into the database
    *
    * @param name        the fqn
    * @param dataMap     the node
    * @param rowMayExist if true, then this method will not be strict in testing for 1 row being inserted, since 0 may be inserted if the row already exists.
    */
   protected void insertNode(Fqn name, Map dataMap, boolean rowMayExist)
   {
      if (getLogger().isTraceEnabled())
           getLogger().trace("insertNode name=" + name + " dataMap=" + dataMap);
      Connection con = null;
      PreparedStatement ps = null;
      try
      {
         con = cf.getConnection();
         ps = prepareAndLogStatement(con, config.getInsertNodeSql());

         populatePreparedStatementForInsert(name, dataMap, ps);

         int rows = ps.executeUpdate();
         if (!rowMayExist && rows != 1)
         {
            throw new IllegalStateException("Expected one insert row but got " + rows);
         }
      }
      catch (RuntimeException e)
      {
         throw e;
      }
      catch (Exception e)
      {
         getLogger().error("Failed to insert node :" + e.getMessage());
         throw new IllegalStateException("Failed to insert node: " + e.getMessage(), e);
      }
      finally
      {
         safeClose(ps);
         cf.close(con);
      }
   }

   /**
    * Expects a PreparedStatement binded to {@link org.jboss.cache.loader.JDBCCacheLoaderConfig#getInsertNodeSql()}
    */
   protected void populatePreparedStatementForInsert(Fqn name, Map dataMap, PreparedStatement ps)
         throws Exception
   {
      String fqnString = name.toString();
      ps.setString(1, fqnString);

      if (dataMap != null)
      {
         ByteBuffer byteBuffer = marshall(dataMap);
         ps.setBinaryStream(2, byteBuffer.getStream(), byteBuffer.getLength());
      }
      else
      {
         // a hack to handles the incomp. of SQL server jdbc driver prior to SQL SERVER 2005
         if (driverName != null && (driverName.contains("SQLSERVER")
               || driverName.contains("POSTGRESQL")
               || driverName.contains("JCONNECT")))
         {
            ps.setNull(2, Types.LONGVARBINARY);
         }
         else
         {
            ps.setNull(2, Types.BLOB);
         }
         //ps.setNull(2, Types.LONGVARBINARY);
      }

      if (name.size() == 0)
      {
         ps.setNull(3, Types.VARCHAR);
      }
      else
      {
         ps.setString(3, name.getAncestor(name.size() - 1).toString());
      }

      // and a repeat - the 4th param is the same as the 1st one.
      ps.setString(4, fqnString);
   }


   /**
    * Updates a node in the database.
    *
    * @param name the fqn
    * @param node new node value
    */
   protected void updateNode(Fqn name, Map<Object, Object> node)
   {
      if (getLogger().isTraceEnabled())
        getLogger().trace("updateNode name=" + name);
      Connection con = null;
      PreparedStatement ps = null;
      try
      {
         con = cf.getConnection();
         ps = prepareAndLogStatement(con, config.getUpdateNodeSql());

         if (node == null) node = EMPTY_HASHMAP;

         ByteBuffer byteBuffer = marshall(node);
         ps.setBinaryStream(1, byteBuffer.getStream(), byteBuffer.getLength());

         ps.setString(2, name.toString());

         /*int rows = */
         ps.executeUpdate();
      }
      catch (Exception e)
      {
         reportAndRethrowError("Failed to update node for fqn " + name, e);
      }
      finally
      {
         safeClose(ps);
         cf.close(con);
      }
   }

   protected String getDriverName(Connection con)
   {
      if (con == null) return null;
      try
      {
         DatabaseMetaData dmd = con.getMetaData();
         return toUpperCase(dmd.getDriverName());
      }
      catch (SQLException e)
      {
         // This should not happen. A J2EE compatiable JDBC driver is
         // required to fully support metadata.
         throw new IllegalStateException("Error while getting the driver name", e);
      }
   }

   static String getRequiredProperty(Properties props, String name)
   {
      String value = props.getProperty(name);
      if (value == null)
      {
         throw new IllegalStateException("Missing required property: " + name);
      }
      return value;
   }

   protected boolean tableExists(String tableName, Connection con)
   {
      ResultSet rs = null;
      try
      {
         // (a j2ee spec compatible jdbc driver has to fully
         // implement the DatabaseMetaData)
         DatabaseMetaData dmd = con.getMetaData();
         String catalog = con.getCatalog();
         String schema = null;
         String quote = dmd.getIdentifierQuoteString();
         if (tableName.startsWith(quote))
         {
            if (!tableName.endsWith(quote))
            {
               throw new IllegalStateException("Mismatched quote in table name: " + tableName);
            }
            int quoteLength = quote.length();
            tableName = tableName.substring(quoteLength, tableName.length() - quoteLength);
            if (dmd.storesLowerCaseQuotedIdentifiers())
            {
               tableName = toLowerCase(tableName);
            }
            else if (dmd.storesUpperCaseQuotedIdentifiers())
            {
               tableName = toUpperCase(tableName);
            }
         }
         else
         {
            if (dmd.storesLowerCaseIdentifiers())
            {
               tableName = toLowerCase(tableName);
            }
            else if (dmd.storesUpperCaseIdentifiers())
            {
               tableName = toUpperCase(tableName);
            }
         }

         int dotIndex;
         if ((dotIndex = tableName.indexOf('.')) != -1)
         {
            // Yank out schema name ...
            schema = tableName.substring(0, dotIndex);
            tableName = tableName.substring(dotIndex + 1);
         }

         rs = dmd.getTables(catalog, schema, tableName, null);
         return rs.next();
      }
      catch (SQLException e)
      {
         // This should not happen. A J2EE compatiable JDBC driver is
         // required fully support metadata.
         throw new IllegalStateException("Error while checking if table aleady exists " + tableName, e);
      }
      finally
      {
         safeClose(rs);
      }
   }


   protected abstract Log getLogger();

   protected abstract AdjListJDBCCacheLoaderConfig processConfig(CacheLoaderConfig.IndividualCacheLoaderConfig base);

   protected void reportAndRethrowError(String message, Exception cause) throws IllegalStateException
   {
      getLogger().error(message, cause);
      throw new IllegalStateException(message, cause);
   }

   protected void safeClose(InputStream is)
   {
      if (is != null)
      {
         try
         {
            is.close();
         }
         catch (IOException e)
         {
            getLogger().warn("Failed to close input stream: " + e.getMessage());
         }
      }
   }

   protected void safeClose(Statement st)
   {
      if (st != null)
      {
         try
         {
            st.close();
         }
         catch (SQLException e)
         {
            getLogger().warn("Failed to close statement: " + e.getMessage());
         }
      }
   }

   protected void safeClose(ResultSet rs)
   {
      if (rs != null)
      {
         try
         {
            rs.close();
         }
         catch (SQLException e)
         {
            getLogger().warn("Failed to close result set: " + e.getMessage());
         }
      }
   }

   protected Object unmarshall(InputStream from) throws Exception
   {
      return getMarshaller().objectFromStream(from);
   }

   protected ByteBuffer marshall(Object obj) throws Exception
   {
      return getMarshaller().objectToBuffer(obj);
   }

   private static String toUpperCase(String s)
   {
      return s.toUpperCase(Locale.ENGLISH);
   }

   private static String toLowerCase(String s)
   {
      return s.toLowerCase((Locale.ENGLISH));
   }

   // Inner

   protected static final Map<Object, Object> NULL_NODE_IN_ROW = new AbstractMap<Object, Object>()
   {

      @Override
      public Set<java.util.Map.Entry<Object, Object>> entrySet()
      {
         throw new UnsupportedOperationException();
      }

   };

}
