/*
 * This file is part of Nuts Framework.
 * Copyright(C) 2009-2012 Nuts Develop Team.
 *
 * Nuts Framework is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License any later version.
 * 
 * Nuts Framework 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Nuts Framework. If not, see <http://www.gnu.org/licenses/>.
 */
package nuts.aws.tomcat.session;


import nuts.core.dao.sql.SqlUtils;
import nuts.core.lang.Strings;
import nuts.core.lang.codec.binary.Base64;
import nuts.core.log.Log;
import nuts.core.log.Logs;

import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

import org.apache.catalina.Container;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.Loader;
import org.apache.catalina.Session;
import org.apache.catalina.Store;
import org.apache.catalina.session.StandardSession;
import org.apache.catalina.session.StoreBase;
import org.apache.catalina.util.CustomObjectInputStream;

import com.amazonaws.auth.AWSCredentials;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.simpledb.AmazonSimpleDB;
import com.amazonaws.services.simpledb.AmazonSimpleDBClient;
import com.amazonaws.services.simpledb.model.Attribute;
import com.amazonaws.services.simpledb.model.BatchDeleteAttributesRequest;
import com.amazonaws.services.simpledb.model.CreateDomainRequest;
import com.amazonaws.services.simpledb.model.DeletableItem;
import com.amazonaws.services.simpledb.model.DeleteAttributesRequest;
import com.amazonaws.services.simpledb.model.Item;
import com.amazonaws.services.simpledb.model.ListDomainsResult;
import com.amazonaws.services.simpledb.model.PutAttributesRequest;
import com.amazonaws.services.simpledb.model.ReplaceableAttribute;
import com.amazonaws.services.simpledb.model.SelectRequest;
import com.amazonaws.services.simpledb.model.SelectResult;

/**
 * Implementation of the <code>Store</code> interface that stores
 * serialized session objects in Amazon's SimpleDB database.  Sessions that are
 * saved are still subject to being expired based on inactivity.
 *
 */
public class SimpleDBStore extends StoreBase implements Store {
	private static Log log = Logs.getLog(SimpleDBStore.class);

	/**
	 * The descriptive information about this implementation.
	 */
	protected static String info = "SimpleDBStore/1.0";
	
	/**
	 * Context name associated with this Store
	 */
	private String name = null;

	/**
	 * Name to register for this Store, used for logging.
	 */
	protected static String storeName = "SimpleDBStore";

	/**
	 * Name to register for the background thread.
	 */
	protected String threadName = "SimpleDBStore";

	/**
	 * The Access Key is associated with your AWS account. You include it in
	 * AWS service requests to identify yourself as the sender of the request.
	 * 
	 * The Access Key is not a secret, and anyone could use your Access Key
	 * in requests to AWS.
	 */
	protected String accessKey;

	/**
	 * To provide proof that you truly are the sender of the request, you also
	 * include a digital signature calculated using your Secret Access Key.
	 */
	protected String secretKey;

	/**
	 * The amazon simple database
	 */
	private AmazonSimpleDB simpleDB = null;

	// ------------------------------------------------------------- Table & cols
	/**
	 * Table to use.
	 */
	protected String sessionTable = "tomcat_sessions";

	/**
	 * Id column to use.
	 */
	protected String sessionIdCol = "id";

	/**
	 * Data column to use.
	 */
	protected String sessionDataCol = "data";

	/**
	 * Is Valid column to use.
	 */
	protected String sessionValidCol = "valid";

	/**
	 * Max Inactive column to use.
	 */
	protected String sessionMaxInactiveCol = "max_inactive";

	/**
	 * Last Accessed column to use.
	 */
	protected String sessionLastAccessedCol = "last_access";

	// ------------------------------------------------------------- Properties

	/**
	 * Return the info for this Store.
	 */
	public String getInfo() {
		return (info);
	}

	/**
	 * Return the name for this instance (built from container name)
	 */
	public String getName() {
		if (name == null) {
			Container container = manager.getContainer();
			String contextName = container.getName();
			String hostName = "";
			String engineName = "";

			if (container.getParent() != null) {
				Container host = container.getParent();
				hostName = host.getName();
				if (host.getParent() != null) {
					engineName = host.getParent().getName();
				}
			}
			name = "/" + engineName + "/" + hostName + contextName;
		}
		return name;
	}

	/**
	 * Return the thread name for this Store.
	 */
	public String getThreadName() {
		return (threadName);
	}

	/**
	 * Return the name for this Store, used for logging.
	 */
	public String getStoreName() {
		return (storeName);
	}

	/**
	 * @return the accessKey
	 */
	public String getAccessKey() {
		return accessKey;
	}

	/**
	 * @param accessKey the accessKey to set
	 */
	public void setAccessKey(String accessKey) {
		String oldAccessKey = this.accessKey;
		this.accessKey = accessKey;
		support.firePropertyChange("accessKey", oldAccessKey, this.accessKey);
		this.accessKey = accessKey;
	}

	/**
	 * @return the secretKey
	 */
	public String getSecretKey() {
		return secretKey;
	}

	/**
	 * @param secretKey the secretKey to set
	 */
	public void setSecretKey(String secretKey) {
		String oldSecretKey = this.secretKey;
		this.secretKey = secretKey;
		support.firePropertyChange("secretKey", oldSecretKey, this.secretKey);
		this.secretKey = secretKey;
	}

	/**
	 * Set the table for this Store.
	 *
	 * @param sessionTable The new table
	 */
	public void setSessionTable(String sessionTable) {
		String oldSessionTable = this.sessionTable;
		this.sessionTable = sessionTable;
		support.firePropertyChange("sessionTable",
				oldSessionTable,
				this.sessionTable);
	}

	/**
	 * Return the table for this Store.
	 */
	public String getSessionTable() {
		return (this.sessionTable);
	}

	/**
	 * Set the Id column for the table.
	 *
	 * @param sessionIdCol the column name
	 */
	public void setSessionIdCol(String sessionIdCol) {
		String oldSessionIdCol = this.sessionIdCol;
		this.sessionIdCol = sessionIdCol;
		support.firePropertyChange("sessionIdCol",
				oldSessionIdCol,
				this.sessionIdCol);
	}

	/**
	 * Return the Id column for the table.
	 */
	public String getSessionIdCol() {
		return (this.sessionIdCol);
	}

	/**
	 * Set the Data column for the table
	 *
	 * @param sessionDataCol the column name
	 */
	public void setSessionDataCol(String sessionDataCol) {
		String oldSessionDataCol = this.sessionDataCol;
		this.sessionDataCol = sessionDataCol;
		support.firePropertyChange("sessionDataCol",
				oldSessionDataCol,
				this.sessionDataCol);
	}

	/**
	 * Return the data column for the table
	 */
	public String getSessionDataCol() {
		return (this.sessionDataCol);
	}

	/**
	 * Set the Is Valid column for the table
	 *
	 * @param sessionValidCol The column name
	 */
	public void setSessionValidCol(String sessionValidCol) {
		String oldSessionValidCol = this.sessionValidCol;
		this.sessionValidCol = sessionValidCol;
		support.firePropertyChange("sessionValidCol",
				oldSessionValidCol,
				this.sessionValidCol);
	}

	/**
	 * Return the Is Valid column
	 */
	public String getSessionValidCol() {
		return (this.sessionValidCol);
	}

	/**
	 * Set the Max Inactive column for the table
	 *
	 * @param sessionMaxInactiveCol The column name
	 */
	public void setSessionMaxInactiveCol(String sessionMaxInactiveCol) {
		String oldSessionMaxInactiveCol = this.sessionMaxInactiveCol;
		this.sessionMaxInactiveCol = sessionMaxInactiveCol;
		support.firePropertyChange("sessionMaxInactiveCol",
				oldSessionMaxInactiveCol,
				this.sessionMaxInactiveCol);
	}

	/**
	 * Return the Max Inactive column
	 */
	public String getSessionMaxInactiveCol() {
		return (this.sessionMaxInactiveCol);
	}

	/**
	 * Set the Last Accessed column for the table
	 *
	 * @param sessionLastAccessedCol The column name
	 */
	public void setSessionLastAccessedCol(String sessionLastAccessedCol) {
		String oldSessionLastAccessedCol = this.sessionLastAccessedCol;
		this.sessionLastAccessedCol = sessionLastAccessedCol;
		support.firePropertyChange("sessionLastAccessedCol",
				oldSessionLastAccessedCol,
				this.sessionLastAccessedCol);
	}

	/**
	 * Return the Last Accessed column
	 */
	public String getSessionLastAccessedCol() {
		return (this.sessionLastAccessedCol);
	}

	// --------------------------------------------------------- Public Methods

	/**
	 * Return an array containing the session identifiers of all Sessions
	 * currently saved in this Store.  If there are no such Sessions, a
	 * zero-length array is returned.
	 *
	 * @exception IOException if an input/output error occurred
	 */
	public String[] keys() throws IOException {
		String keys[] = null;

		synchronized (this) {
			AmazonSimpleDB _db = getSimpleDB();
			if (_db == null) {
				return (Strings.EMPTY_ARRAY);
			}
			
			try {
				String sql = "SELECT " 
					+ sessionIdCol 
					+ " FROM "
					+ sessionTable;
	
				SelectRequest sreq = new SelectRequest(sql);
				SelectResult sres = _db.select(sreq);
				
				List<Item> items = sres.getItems();
				keys = new String[items.size()];
				for (int i = 0; i < items.size(); i++) {
					Item it = items.get(i);
					keys[i] = it.getName();
				}
			}
			catch (Exception e) {
				log.error(getStoreName() + ".keys", e);
				keys = Strings.EMPTY_ARRAY;
			}
		}
		
		return (keys);
	}

	/**
	 * Return an integer containing a count of all Sessions
	 * currently saved in this Store.  If there are no Sessions,
	 * <code>0</code> is returned.
	 *
	 * @exception IOException if an input/output error occurred
	 */
	public int getSize() throws IOException {
		int size = 0;

		synchronized (this) {
			AmazonSimpleDB _db = getSimpleDB();
			if (_db == null) {
				return (size);
			}

			try {
				String sql = "SELECT COUNT(*) FROM " + sessionTable;

				SelectRequest sreq = new SelectRequest(sql);
				SelectResult sres = _db.select(sreq);
				
				List<Item> items = sres.getItems();
				for (Item it : items) {
					List<Attribute> as = it.getAttributes();
					for (Attribute a : as) {
						size = Integer.valueOf(a.getValue());
						break;
					}
					break;
				}
			} 
			catch (Exception e) {
				log.error(getStoreName() + ".getSize", e);
			}
		}
		return (size);
	}

	/**
	 * Load the Session associated with the id <code>id</code>.
	 * If no such session is found <code>null</code> is returned.
	 *
	 * @param id a value of type <code>String</code>
	 * @return the stored <code>Session</code>
	 * @exception ClassNotFoundException if an error occurs
	 * @exception IOException if an input/output error occurred
	 */
	public Session load(String id) throws ClassNotFoundException, IOException {
		StandardSession _session = null;
		Loader loader = null;
		ClassLoader classLoader = null;
		ObjectInputStream ois = null;
		InputStream bis = null;
		Container container = manager.getContainer();
 
		synchronized (this) {
			AmazonSimpleDB _db = getSimpleDB();
			if (_db == null) {
				return (null);
			}
	
			try {
				String sql = "SELECT " + sessionDataCol 
					+ " FROM " + sessionTable
					+ " WHERE " 
					+ sessionIdCol + " = '" + SqlUtils.escapeSql(id) + "'";
	
				SelectRequest sreq = new SelectRequest(sql);
				SelectResult sres = _db.select(sreq);
				
				String val = null;
				List<Item> items = sres.getItems();
				for (Item it : items) {
					List<Attribute> as = it.getAttributes();
					for (Attribute a : as) {
						val = a.getValue();
						break;
					}
					break;
				}
	
				if (Strings.isNotEmpty(val)) {
					bis = new ByteArrayInputStream(Base64.decodeBase64(val));
	
					if (container != null) {
						loader = container.getLoader();
					}
					if (loader != null) {
						classLoader = loader.getClassLoader();
					}
					if (classLoader != null) {
						ois = new CustomObjectInputStream(bis, classLoader);
					} 
					else {
						ois = new ObjectInputStream(bis);
					}
	
					if (log.isDebugEnabled()) {
						log.debug(getStoreName() + ".load(" + id + "," + sessionTable + ")");
					}
	
					_session = (StandardSession) manager.createEmptySession();
					_session.readObjectData(ois);
					_session.setManager(manager);
				}
				else {
					if (log.isDebugEnabled()) {
						log.debug(getStoreName() + ".load(" + id + "," + sessionTable + "): No persisted data object found");
					}
				}
			} 
			catch (Exception e) {
				log.error(getStoreName() + ".load", e);
			}
		}

		return (_session);
	}

	/**
	 * Remove the Session with the specified session identifier from
	 * this Store, if present.	If no such Session is present, this method
	 * takes no action.
	 *
	 * @param id Session identifier of the Session to be removed
	 *
	 * @exception IOException if an input/output error occurs
	 */
	public void remove(String id) throws IOException {
		synchronized (this) {
			AmazonSimpleDB _db = getSimpleDB();
			if (_db == null) {
				return;
			}

			try {
				DeleteAttributesRequest dreq = new DeleteAttributesRequest(sessionTable, id);
				_db.deleteAttributes(dreq);
			} 
			catch (Exception e) {
				log.error(getStoreName() + ".remove", e);
			}
		}

		if (log.isDebugEnabled()) {
			log.debug(getStoreName() + ".remove(" + id + ", " + sessionTable + ")");
		}
	}

	/**
	 * Remove all of the Sessions in this Store.
	 *
	 * @exception IOException if an input/output error occurs
	 */
	public void clear() throws IOException {
		synchronized (this) {
			AmazonSimpleDB _db = getSimpleDB();
			if (_db == null) {
				return;
			}

			try {
				String sql = "SELECT " 
					+ sessionIdCol 
					+ " FROM "
					+ sessionTable;
	
				SelectRequest sreq = new SelectRequest(sql);
				SelectResult sres = _db.select(sreq);
				
				List<Item> items = sres.getItems();
				List<DeletableItem> ditems = new ArrayList<DeletableItem>(items.size());
				
				for (int i = 0; i < items.size(); i++) {
					Item it = items.get(i);
					ditems.add(new DeletableItem().withName(it.getName()));
				}
				
				BatchDeleteAttributesRequest bdareq = new BatchDeleteAttributesRequest(sessionTable, ditems);
				_db.batchDeleteAttributes(bdareq);
			}
			catch (Exception e) {
				log.error(getStoreName() + ".clear", e);
			}
		}
	}

	/**
	 * Save a session to the Store.
	 *
	 * @param session the session to be stored
	 * @exception IOException if an input/output error occurs
	 */
	public void save(Session session) throws IOException {
		synchronized (this) {
			AmazonSimpleDB _db = getSimpleDB();
			if (_db == null) {
				return;
			}

			try {
				List<ReplaceableAttribute> as = new ArrayList<ReplaceableAttribute>();

				ByteArrayOutputStream bos = new ByteArrayOutputStream();
				ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(bos));
				((StandardSession) session).writeObjectData(oos);
				oos.close();

				as.add(new ReplaceableAttribute(sessionIdCol, session.getIdInternal(), true));
				as.add(new ReplaceableAttribute(sessionValidCol, session.isValid() ? "1" : "0", true));
				as.add(new ReplaceableAttribute(sessionMaxInactiveCol, String.valueOf(session.getMaxInactiveInterval()), true));
				as.add(new ReplaceableAttribute(sessionLastAccessedCol, String.valueOf(session.getLastAccessedTime()), true));
				as.add(new ReplaceableAttribute(sessionDataCol, Base64.encodeBase64String(bos.toByteArray()), true));

				PutAttributesRequest pareq = new PutAttributesRequest(sessionTable, session.getIdInternal(), as);

				_db.putAttributes(pareq);
			} 
			catch (Exception e) {
				log.error(getStoreName() + ".save", e);
			}
		}

		if (log.isDebugEnabled()) {
			log.debug(getStoreName() + ".save(" + session.getIdInternal() + ", " + sessionTable + ")");
		}
	}

	// --------------------------------------------------------- Protected Methods

	/**
	 * Check the connection associated with this store, if it's
	 * <code>null</code> or closed try to reopen it.
	 * Returns <code>null</code> if the connection could not be established.
	 *
	 * @return <code>Connection</code> if the connection succeeded
	 */
	protected AmazonSimpleDB getSimpleDB() {
		try {
			if (simpleDB == null) {
				open();
				if (simpleDB == null) {
					log.error(getStoreName() + ".open failed!");
				}
			}
		}
		catch (Exception ex) {
			log.error(getStoreName() + ".getSimpleDB", ex);
		}

		return simpleDB;
	}

	/**
	 * Open (if necessary) and return a database connection for use by
	 * this Realm.
	 *
	 * @exception SQLException if a database error occurs
	 */
	protected AmazonSimpleDB open() throws Exception {
		// Do nothing if there is a database connection already open
		if (simpleDB != null)
			return (simpleDB);

		log.info(getStoreName() + ".open: " + sessionTable);

		AWSCredentials c = new BasicAWSCredentials(accessKey, secretKey);
		simpleDB = new AmazonSimpleDBClient(c);

		ListDomainsResult ldres = simpleDB.listDomains();
		if (!ldres.getDomainNames().contains(sessionTable)) {
			CreateDomainRequest cdreq = new CreateDomainRequest(sessionTable);
			simpleDB.createDomain(cdreq);
			log.info(getStoreName() + ".createDomain: " + sessionTable);
		}
		
		return simpleDB;

	}

	/**
	 * Called once when this Store is first started.
	 */
	public void start() throws LifecycleException {
		super.start();

		// Open connection to the database
		getSimpleDB();
	}

	/**
	 * Gracefully terminate everything associated with our db.
	 * Called once when this Store is stopping.
	 *
	 */
	public void stop() throws LifecycleException {
		super.stop();
	}
}
