
package com.oculusinfo.binning.io.impl;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;

import com.oculusinfo.binning.TileData;
import com.oculusinfo.binning.TileIndex;
import com.oculusinfo.binning.io.PyramidIO;
import com.oculusinfo.binning.io.serialization.TileSerializer;
import org.json.JSONObject;

/**
 * JDBC-based implementation of PyramidIO.
 *
 * @author rcameron
 *
 */
public class JDBCPyramidIO implements PyramidIO {
	private static final String TABLE_METADATA = "metadata";
	private static final int BATCH_SIZE = 10000;
	private static final String COL_ZOOM_LVL = "zoom_level";
	private static final String COL_TILE_COLUMN = "tile_column";
	private static final String COL_TILE_ROW = "tile_row";
	private static final String COL_TILE_DATA = "tile_data";
	private static final String COL_METADATA = "metadata";
	private static final String COL_PYRAMID_ID = "pyramid_id";

	private Connection _connection;

	public JDBCPyramidIO(String driverClassName, String dbUrl) throws ClassNotFoundException, SQLException {
		Class.forName(driverClassName);
		_connection = DriverManager.getConnection(dbUrl);
	}

	public void shutdown() {
		try {
			_connection.close();
		} catch (SQLException e) {
			e.printStackTrace();
		}
	}

	@Override
	public void initializeForWrite(String pyramidId) throws IOException {
		// Create the table and columns if necessary.
		Statement stmt = null;
		try {
			if (!tableExists(pyramidId)) {
				StringBuilder sb = new StringBuilder();
				sb.append("CREATE TABLE ");
				sb.append(toTableName(pyramidId));
				sb.append(" (");
				sb.append(COL_ZOOM_LVL);
				sb.append(" INTEGER NOT NULL, ");
				sb.append(COL_TILE_COLUMN);
				sb.append(" INTEGER NOT NULL, ");
				sb.append(COL_TILE_ROW);
				sb.append(" INTEGER NOT NULL, ");
				sb.append(COL_TILE_DATA);
				sb.append(" BLOB,");
				sb.append(" CONSTRAINT pk_TileIndex PRIMARY KEY (");
				sb.append(COL_ZOOM_LVL);
				sb.append(",");
				sb.append(COL_TILE_COLUMN);
				sb.append(",");
				sb.append(COL_TILE_ROW);
				sb.append("))");

				stmt = _connection.createStatement();
				stmt.executeUpdate(sb.toString());
			}

			if (!tableExists(TABLE_METADATA)) {
				StringBuilder sb = new StringBuilder();
				sb.append("CREATE TABLE ");
				sb.append(TABLE_METADATA);
				sb.append(" (");
				sb.append(COL_PYRAMID_ID);
				sb.append(" TEXT PRIMARY KEY, metadata TEXT)");

				if (stmt == null) stmt = _connection.createStatement();
				stmt.executeUpdate(sb.toString());
			}
		} catch (Exception e) {
			throw new IOException("Error initializing for write: ", e);
		} finally {
			if (stmt != null) {
				try {
					stmt.close();
				} catch (SQLException e) {
					e.printStackTrace();
				}
			}
		}
	}

	// We wrap pyramid ids in [] so we can use a wider range of characters in
	// pyramid IDs.
	private String toTableName(String pyramidId) {
		StringBuffer sb = new StringBuffer("\"");
		sb.append(pyramidId).append("\"");
		return sb.toString();
	}

	protected boolean tableExists(String pyramidId) throws SQLException {
		StringBuilder sb = new StringBuilder();
		sb.append("SELECT name FROM 'sqlite_master' WHERE type='table' AND name=");
		sb.append(toTableName(pyramidId));

		Statement stmt = null;
		boolean exists = false;

		try {
			stmt = _connection.createStatement();
			stmt.execute(sb.toString());
			exists = stmt.getResultSet().next();
		} finally {
			if (stmt != null) {
				stmt.close();
			}
		}

		return exists;
	}

	@Override
	public <T> void writeTiles(String pyramidId,
	                           TileSerializer<T> serializer, Iterable<TileData<T>> data)
		throws IOException {
		PreparedStatement ps = null;

		try {
			_connection.setAutoCommit(false);

			StringBuilder sb = new StringBuilder();
			sb.append("INSERT INTO ");
			sb.append(toTableName(pyramidId));
			sb.append(" (");
			sb.append(COL_ZOOM_LVL);
			sb.append(",");
			sb.append(COL_TILE_COLUMN);
			sb.append(",");
			sb.append(COL_TILE_ROW);
			sb.append(",");
			sb.append(COL_TILE_DATA);
			sb.append(") ");
			sb.append("VALUES (?,?,?,?)");
			ps = _connection.prepareStatement(sb.toString());

			int count = 0;
			for (TileData<T> tile : data) {
				ByteArrayOutputStream baos = new ByteArrayOutputStream();
				serializer.serialize(tile, baos);

				TileIndex index = tile.getDefinition();

				ps.setInt(1, index.getLevel());
				ps.setInt(2, index.getX());
				ps.setInt(3, index.getY());
				ps.setBytes(4, baos.toByteArray());

				ps.addBatch();

				++count;
				if (count % BATCH_SIZE == 0) {
					ps.executeBatch();
				}
			}

			ps.executeBatch();
			_connection.commit();
		} catch (Exception e) {
			throw new IOException("Error writing tiles.", e);
		} finally {
			try {
				if (ps != null) {
					ps.close();
				}
				_connection.setAutoCommit(true);
			} catch (SQLException e) {
				e.printStackTrace();
			}
		}

	}

	protected boolean metaDataExistsFor(String pyramidId) {
		String metadata = null;
		try {
			metadata = readMetaData(pyramidId);
		} catch (IOException e) {
			metadata = null;
		}
		return metadata != null;
	}

	@Override
	public void writeMetaData(String pyramidId, String metaData)
		throws IOException {
		Statement stmt = null;
		try {
			StringBuilder sb = new StringBuilder();
			if (metaDataExistsFor(pyramidId)) {
				sb.append("UPDATE ");
				sb.append(TABLE_METADATA);
				sb.append(" SET ");
				sb.append(COL_METADATA);
				sb.append(" = '");
				sb.append(metaData);
				sb.append("' WHERE ");
				sb.append(COL_PYRAMID_ID);
				sb.append(" = '");
				sb.append(toTableName(pyramidId));
				sb.append("';");
			}
			else {
				sb.append("INSERT INTO ");
				sb.append(TABLE_METADATA);
				sb.append(" (");
				sb.append(COL_PYRAMID_ID);
				sb.append(", ");
				sb.append(COL_METADATA);
				sb.append(") VALUES('");
				sb.append(toTableName(pyramidId));
				sb.append("','");
				sb.append(metaData);
				sb.append("')");
			}

			stmt = _connection.createStatement();
			stmt.execute(sb.toString());
		} catch (SQLException e) {
			throw new IOException("Error writing metadata.", e);
		} finally {
			if (stmt != null) {
				try {
					stmt.close();
				} catch (SQLException e) {
					e.printStackTrace();
				}
			}
		}
	}

	@Override
	public void initializeForRead(String pyramidId, int width, int height, Properties dataDescription) {
		// Noop
	}

	@Override
	public <T> List<TileData<T>> readTiles(String pyramidId,
	                                       TileSerializer<T> serializer, Iterable<TileIndex> tiles)
		throws IOException {
		PreparedStatement ps = null;
		try {
			if (!tableExists(pyramidId)) {
				// TODO: Right thing to return when the table doesn't exist?
				return null;
			}

			StringBuilder sb = new StringBuilder();
			sb.append("SELECT ");
			sb.append(COL_TILE_DATA);
			sb.append(" FROM ");
			sb.append(toTableName(pyramidId));
			sb.append(" WHERE ");
			sb.append(COL_ZOOM_LVL);
			sb.append(" = ? AND ");
			sb.append(COL_TILE_COLUMN);
			sb.append(" = ? AND ");
			sb.append(COL_TILE_ROW);
			sb.append(" = ?");

			ps = _connection.prepareStatement(sb.toString());

			List<TileData<T>> results = new LinkedList<TileData<T>>();
			for (TileIndex tile : tiles) {
				ps.setInt(1, tile.getLevel());
				ps.setInt(2, tile.getX());
				ps.setInt(3, tile.getY());

				ResultSet resultSet = ps.executeQuery();
				if (!resultSet.next())
					continue;

				byte[] tileBytes = resultSet.getBytes(COL_TILE_DATA);

				TileData<T> data = serializer.deserialize(tile,
				                                          new ByteArrayInputStream(tileBytes));
				results.add(data);
			}
			return results;

		} catch (Exception e) {
			throw new IOException("Error reading tiles.", e);
		} finally {
			if (ps != null) {
				try {
					ps.close();
				} catch (SQLException e) {
					throw new IOException(e);
				}
			}
		}
	}

	@Override
	public <T> List<TileData<T>> readTiles (String pyramidId,
											TileSerializer<T> serializer,
											Iterable<TileIndex> tiles,
											JSONObject properties ) throws IOException {
		return readTiles( pyramidId, serializer, tiles );
	}

	@Override
	public <T> InputStream getTileStream (String pyramidId,
	                                      TileSerializer<T> serializer,
	                                      TileIndex tile) throws IOException {
		PreparedStatement ps = null;
		try {
			if (!tableExists(pyramidId)) {
				// TODO: Right thing to return when the table doesn't exist?
				return null;
			}

			StringBuilder sb = new StringBuilder();
			sb.append("SELECT ");
			sb.append(COL_TILE_DATA);
			sb.append(" FROM ");
			sb.append(toTableName(pyramidId));
			sb.append(" WHERE ");
			sb.append(COL_ZOOM_LVL);
			sb.append(" = ? AND ");
			sb.append(COL_TILE_COLUMN);
			sb.append(" = ? AND ");
			sb.append(COL_TILE_ROW);
			sb.append(" = ?");

			ps = _connection.prepareStatement(sb.toString());
			ps.setInt(1, tile.getLevel());
			ps.setInt(2, tile.getX());
			ps.setInt(3, tile.getY());

			ResultSet resultSet = ps.executeQuery();
			if (resultSet.next()) {
				byte[] tileBytes = resultSet.getBytes(COL_TILE_DATA);
				return new ByteArrayInputStream(tileBytes);
			}
		} catch (Exception e) {
			throw new IOException("Error reading tiles.", e);
		} finally {
			if (ps != null) {
				try {
					ps.close();
				} catch (SQLException e) {
					throw new IOException(e);
				}
			}
		}
		return null;
	}

	@Override
	public String readMetaData(String pyramidId) throws IOException {
		Statement stmt = null;
		try {
			if (!tableExists(TABLE_METADATA)) {
				return null;
			}

			StringBuilder sb = new StringBuilder();
			sb.append("SELECT ");
			sb.append(COL_METADATA);
			sb.append(" FROM ");
			sb.append(TABLE_METADATA);
			sb.append(" WHERE ");
			sb.append(COL_PYRAMID_ID);
			sb.append(" = '");
			sb.append(toTableName(pyramidId));
			sb.append("'");

			stmt = _connection.createStatement();
			ResultSet resultSet = stmt.executeQuery(sb
			                                        .toString());
			if (!resultSet.next())
				return null;

			return resultSet.getString(TABLE_METADATA);
		} catch (SQLException e) {
			throw new IOException("Error reading tiles.", e);
		} finally {
			if (stmt != null) {
				try {
					stmt.close();
				} catch (SQLException e) {
					e.printStackTrace();
				}
			}
		}
	}

	@Override
	public void removeTiles (String id, Iterable<TileIndex> tiles ) throws IOException {
		throw new IOException("removeTiles not currently supported for JDBCPyramidIO");
	}

}
