package org.jboss.cache.loader;

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

import java.io.ByteArrayInputStream;
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();
   protected static Log log = LogFactory.getLog(AdjListJDBCCacheLoader.class);

   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 */
            log.debug("Initialising with a connection factory since data source is not provided.");
            if (log.isDebugEnabled())
               log.debug("Using connection factory " + config.getConnectionFactoryClass());
            cf = (ConnectionFactory) Util.loadClass(config.getConnectionFactoryClass()).newInstance();
         }
         catch (Exception e)
         {
            log.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
      {
         if (log.isDebugEnabled())
         {
            log.debug("executing sql: " + config.getSelectChildNamesSql() + " (" + fqn + ")");
         }

         con = cf.getConnection();
         ps = con.prepareStatement(config.getSelectChildNamesSql());
         ps.setString(1, fqn.toString());
         lock.acquireLock(fqn, false);
         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 (log.isDebugEnabled())
            {
               log.debug("executing ddl: " + config.getCreateTableDDL());
            }
            st = con.createStatement();
            st.executeUpdate(config.getCreateTableDDL());
         }
      }
      finally
      {
         safeClose(st);
         cf.close(con);
      }

      createDummyTableIfNeeded();
   }

   private void createDummyTableIfNeeded() throws Exception
   {
      Connection conn = null;
      PreparedStatement ps = null;
      try
      {
         conn = cf.getConnection();
         ps = conn.prepareStatement(config.getDummyTableRemovalDDL());
         ps.execute();
      }
      catch (Exception e)
      {
         // ignore - it just means we didn't need to drop any database tables.
      }
      finally
      {
         safeClose(ps);
         cf.close(conn);
      }

      try
      {
         conn = cf.getConnection();
         ps = conn.prepareStatement(config.getDummyTableCreationDDL());
         ps.execute();
         safeClose(ps);
         ps = conn.prepareStatement(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 (log.isDebugEnabled())
               {
                  log.debug("executing ddl: " + config.getDropTableDDL());
               }

               con = cf.getConnection();
               st = con.createStatement();
               st.executeUpdate(config.getDropTableDDL());
               safeClose(st);
            }
            catch (SQLException e)
            {
               log.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
   {
      lock.acquireLock(name, false);
      Connection conn = null;
      PreparedStatement ps = null;
      ResultSet rs = null;
      try
      {
         conn = cf.getConnection();
         ps = conn.prepareStatement(config.getExistsSql());
         ps.setString(1, name.toString());
         rs = ps.executeQuery();
         return rs.next();
      }
      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
   {
      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)
   {
      boolean rowExists = false;
      Connection con = null;
      PreparedStatement ps = null;
      ResultSet rs = null;
      try
      {
         if (log.isDebugEnabled())
         {
            log.debug("executing sql: " + config.getSelectNodeSql() + " (" + name + ")");
         }

         con = cf.getConnection();
         ps = con.prepareStatement(config.getSelectNodeSql());
         ps.setString(1, 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 node        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 node, boolean rowMayExist)
   {
      Connection con = null;
      PreparedStatement ps = null;
      try
      {
         if (log.isDebugEnabled())
         {
            log.debug("executing sql: " + config.getInsertNodeSql() + " (" + name + ")");
         }

         con = cf.getConnection();
         ps = con.prepareStatement(config.getInsertNodeSql());

         String fqnString = name.toString();

         // the Fqn needs to be in the 1st and 4th places.
         ps.setString(1, fqnString);
         ps.setString(4, fqnString);

         if (node != null)
         {
            //            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            //            ObjectOutputStream oos = new ObjectOutputStream(baos);
            //            oos.writeObject(node);

            byte[] byteStream = marshall(node);
            ByteArrayInputStream bais = new ByteArrayInputStream(byteStream);
            ps.setBinaryStream(2, bais, byteStream.length);
         }
         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());
         }

         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)
      {
         log.error("Failed to insert node :" + e.getMessage());
         throw new IllegalStateException("Failed to insert node: " + e.getMessage(), e);
      }
      finally
      {
         safeClose(ps);
         cf.close(con);
      }
   }


   /**
    * Updates a node in the database.
    *
    * @param name the fqn
    * @param node new node value
    */
   protected void updateNode(Fqn name, Map<Object, Object> node)
   {
      Connection con = null;
      PreparedStatement ps = null;
      try
      {
         if (log.isDebugEnabled())
         {
            log.debug("executing sql: " + config.getUpdateNodeSql());
         }

         con = cf.getConnection();
         ps = con.prepareStatement(config.getUpdateNodeSql());

         if (node == null)
         {
            //ps.setNull(1, Types.BLOB);
            //            ps.setNull(1, Types.LONGVARBINARY);
            // don't set it to null - simply use an empty hash map.
            node = new HashMap<Object, Object>(0);
         }

         //         ByteArrayOutputStream baos = new ByteArrayOutputStream();
         //         ObjectOutputStream oos = new ObjectOutputStream(baos);
         //         oos.writeObject(node);

         byte[] byteStream = marshall(node);
         ByteArrayInputStream bais = new ByteArrayInputStream(byteStream);
         ps.setBinaryStream(1, bais, byteStream.length);

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

         /*int rows = */
         ps.executeUpdate();
         //         if (rows != 1)
         //         {
         //            throw new IllegalStateException("Expected one updated row but got " + rows);
         //         }
      }
      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 AdjListJDBCCacheLoaderConfig processConfig(CacheLoaderConfig.IndividualCacheLoaderConfig base);

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

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

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

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

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

   protected byte[] marshall(Object obj) throws Exception
   {
      return getMarshaller().objectToByteBuffer(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();
      }

   };

}
