package mil.nga.geopackage.tiles.features;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.io.IOException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.locationtech.proj4j.units.Units;

import com.j256.ormlite.dao.CloseableIterator;

import mil.nga.geopackage.BoundingBox;
import mil.nga.geopackage.GeoPackage;
import mil.nga.geopackage.GeoPackageException;
import mil.nga.geopackage.extension.index.FeatureTableIndex;
import mil.nga.geopackage.extension.index.GeometryIndex;
import mil.nga.geopackage.extension.style.FeatureStyle;
import mil.nga.geopackage.extension.style.FeatureTableStyles;
import mil.nga.geopackage.extension.style.IconCache;
import mil.nga.geopackage.extension.style.IconDao;
import mil.nga.geopackage.extension.style.IconRow;
import mil.nga.geopackage.extension.style.StyleDao;
import mil.nga.geopackage.extension.style.StyleRow;
import mil.nga.geopackage.features.user.FeatureDao;
import mil.nga.geopackage.features.user.FeatureResultSet;
import mil.nga.geopackage.features.user.FeatureRow;
import mil.nga.geopackage.property.GeoPackageJavaProperties;
import mil.nga.geopackage.property.JavaPropertyConstants;
import mil.nga.geopackage.tiles.ImageUtils;
import mil.nga.geopackage.tiles.TileBoundingBoxUtils;
import mil.nga.geopackage.tiles.TileUtils;
import mil.nga.sf.GeometryType;
import mil.nga.sf.Point;
import mil.nga.sf.proj.Projection;
import mil.nga.sf.proj.ProjectionConstants;
import mil.nga.sf.proj.ProjectionFactory;
import mil.nga.sf.proj.ProjectionTransform;
import mil.nga.sf.util.GeometryUtils;

/**
 * Tiles generated from features
 *
 * @author osbornb
 * @since 1.1.2
 */
public abstract class FeatureTiles {

	/**
	 * Logger
	 */
	private static final Logger LOGGER = Logger
			.getLogger(FeatureTiles.class.getName());

	/**
	 * WGS84 Projection
	 */
	protected static final Projection WGS_84_PROJECTION = ProjectionFactory
			.getProjection(ProjectionConstants.EPSG_WORLD_GEODETIC_SYSTEM);

	/**
	 * Web Mercator Projection
	 */
	protected static final Projection WEB_MERCATOR_PROJECTION = ProjectionFactory
			.getProjection(ProjectionConstants.EPSG_WEB_MERCATOR);

	/**
	 * Tile data access object
	 */
	protected final FeatureDao featureDao;

	/**
	 * Feature DAO Projection
	 */
	protected Projection projection;

	/**
	 * When not null, features are retrieved using a feature index
	 */
	protected FeatureTableIndex featureIndex;

	/**
	 * Feature Style extension
	 */
	protected FeatureTableStyles featureTableStyles;

	/**
	 * Tile height
	 */
	protected int tileWidth;

	/**
	 * Tile height
	 */
	protected int tileHeight;

	/**
	 * Compress format
	 */
	protected String compressFormat;

	/**
	 * Point radius
	 */
	protected float pointRadius;

	/**
	 * Point paint
	 */
	protected Paint pointPaint = new Paint();

	/**
	 * Optional point icon in place of a drawn circle
	 */
	protected FeatureTilePointIcon pointIcon;

	/**
	 * Line paint
	 */
	protected Paint linePaint = new Paint();

	/**
	 * Line stroke width
	 */
	protected float lineStrokeWidth;

	/**
	 * Polygon paint
	 */
	protected Paint polygonPaint = new Paint();

	/**
	 * Polygon stroke width
	 */
	protected float polygonStrokeWidth;

	/**
	 * Fill polygon flag
	 */
	protected boolean fillPolygon;

	/**
	 * Polygon fill paint
	 */
	protected Paint polygonFillPaint = new Paint();

	/**
	 * Feature paint cache
	 */
	private FeaturePaintCache featurePaintCache = new FeaturePaintCache();

	/**
	 * Icon Cache
	 */
	private IconCache iconCache = new IconCache();

	/**
	 * Height overlapping pixels between tile images
	 */
	protected float heightOverlap;

	/**
	 * Width overlapping pixels between tile images
	 */
	protected float widthOverlap;

	/**
	 * Optional max features per tile. When more features than this value exist
	 * for creating a single tile, the tile is not created
	 */
	protected Integer maxFeaturesPerTile;

	/**
	 * When not null and the number of features is greater than the max features
	 * per tile, used to draw tiles for those tiles with more features than the
	 * max
	 *
	 * @see CustomFeaturesTile
	 * @see mil.nga.geopackage.tiles.features.custom.NumberFeaturesTile custom
	 *      features tile implementation
	 */
	protected CustomFeaturesTile maxFeaturesTileDraw;

	/**
	 * When true, geometries are simplified before being drawn. Default is true
	 */
	protected boolean simplifyGeometries = true;

	/**
	 * Scale factor
	 */
	protected float scale = 1.0f;

	/**
	 * Constructor
	 *
	 * @param featureDao
	 *            feature dao
	 */
	public FeatureTiles(FeatureDao featureDao) {
		this(null, featureDao);
	}

	/**
	 * Constructor
	 *
	 * @param featureDao
	 *            feature dao
	 * @param scale
	 *            scale factor
	 * @since 3.2.0
	 */
	public FeatureTiles(FeatureDao featureDao, float scale) {
		this(null, featureDao, scale);
	}

	/**
	 * Constructor
	 *
	 * @param featureDao
	 *            feature dao
	 * @param width
	 *            drawn tile width
	 * @param height
	 *            drawn tile height
	 * @since 3.2.0
	 */
	public FeatureTiles(FeatureDao featureDao, int width, int height) {
		this(null, featureDao, width, height);
	}

	/**
	 * Constructor, auto creates the index manager for indexed tables and
	 * feature styles for styled tables
	 *
	 * @param geoPackage
	 *            GeoPackage
	 * @param featureDao
	 *            feature dao
	 * @since 3.2.0
	 */
	public FeatureTiles(GeoPackage geoPackage, FeatureDao featureDao) {
		this(geoPackage, featureDao, TileUtils.TILE_PIXELS_HIGH,
				TileUtils.TILE_PIXELS_HIGH);
	}

	/**
	 * Constructor, auto creates the index manager for indexed tables and
	 * feature styles for styled tables
	 *
	 * @param geoPackage
	 *            GeoPackage
	 * @param featureDao
	 *            feature dao
	 * @param scale
	 *            scale factor
	 * @since 3.2.0
	 */
	public FeatureTiles(GeoPackage geoPackage, FeatureDao featureDao,
			float scale) {
		this(geoPackage, featureDao, scale, TileUtils.tileLength(scale),
				TileUtils.tileLength(scale));
	}

	/**
	 * Constructor, auto creates the index manager for indexed tables and
	 * feature styles for styled tables
	 *
	 * @param geoPackage
	 *            GeoPackage
	 * @param featureDao
	 *            feature dao
	 * @param width
	 *            drawn tile width
	 * @param height
	 *            drawn tile height
	 * @since 3.2.0
	 */
	public FeatureTiles(GeoPackage geoPackage, FeatureDao featureDao, int width,
			int height) {
		this(geoPackage, featureDao, TileUtils.tileScale(width, height), width,
				height);
	}

	/**
	 * Constructor, auto creates the index manager for indexed tables and
	 * feature styles for styled tables
	 *
	 * @param geoPackage
	 *            GeoPackage
	 * @param featureDao
	 *            feature dao
	 * @param scale
	 *            scale factor
	 * @param width
	 *            drawn tile width
	 * @param height
	 *            drawn tile height
	 * @since 3.2.0
	 */
	public FeatureTiles(GeoPackage geoPackage, FeatureDao featureDao,
			float scale, int width, int height) {

		this.featureDao = featureDao;
		if (featureDao != null) {
			this.projection = featureDao.getProjection();
		}

		this.scale = scale;

		tileWidth = width;
		tileHeight = height;

		compressFormat = GeoPackageJavaProperties.getProperty(
				JavaPropertyConstants.FEATURE_TILES,
				JavaPropertyConstants.FEATURE_TILES_COMPRESS_FORMAT);

		pointRadius = GeoPackageJavaProperties.getFloatProperty(
				JavaPropertyConstants.FEATURE_TILES_POINT,
				JavaPropertyConstants.FEATURE_TILES_RADIUS);
		pointPaint.setColor(GeoPackageJavaProperties.getColorProperty(
				JavaPropertyConstants.FEATURE_TILES_POINT,
				JavaPropertyConstants.FEATURE_TILES_COLOR));

		lineStrokeWidth = GeoPackageJavaProperties.getFloatProperty(
				JavaPropertyConstants.FEATURE_TILES_LINE,
				JavaPropertyConstants.FEATURE_TILES_STROKE_WIDTH);
		linePaint.setStrokeWidth(this.scale * lineStrokeWidth);
		linePaint.setColor(GeoPackageJavaProperties.getColorProperty(
				JavaPropertyConstants.FEATURE_TILES_LINE,
				JavaPropertyConstants.FEATURE_TILES_COLOR));

		polygonStrokeWidth = GeoPackageJavaProperties.getFloatProperty(
				JavaPropertyConstants.FEATURE_TILES_POLYGON,
				JavaPropertyConstants.FEATURE_TILES_STROKE_WIDTH);
		polygonPaint.setStrokeWidth(this.scale * polygonStrokeWidth);
		polygonPaint.setColor(GeoPackageJavaProperties.getColorProperty(
				JavaPropertyConstants.FEATURE_TILES_POLYGON,
				JavaPropertyConstants.FEATURE_TILES_COLOR));

		fillPolygon = GeoPackageJavaProperties.getBooleanProperty(
				JavaPropertyConstants.FEATURE_TILES_POLYGON_FILL);
		polygonFillPaint.setColor(GeoPackageJavaProperties.getColorProperty(
				JavaPropertyConstants.FEATURE_TILES_POLYGON_FILL,
				JavaPropertyConstants.FEATURE_TILES_COLOR));

		if (geoPackage != null) {

			featureIndex = new FeatureTableIndex(geoPackage, featureDao);
			if (!featureIndex.isIndexed()) {
				featureIndex.close();
				featureIndex = null;
			}

			featureTableStyles = new FeatureTableStyles(geoPackage,
					featureDao.getTable());
			if (!featureTableStyles.has()) {
				featureTableStyles = null;
			}

		}

		calculateDrawOverlap();
	}

	/**
	 * Call after making changes to the point icon, point radius, or paint
	 * stroke widths. Determines the pixel overlap between tiles
	 */
	public void calculateDrawOverlap() {

		if (pointIcon != null) {
			heightOverlap = this.scale * pointIcon.getHeight();
			widthOverlap = this.scale * pointIcon.getWidth();
		} else {
			heightOverlap = this.scale * pointRadius;
			widthOverlap = this.scale * pointRadius;
		}

		float linePaintHalfStroke = this.scale * lineStrokeWidth / 2.0f;
		heightOverlap = Math.max(heightOverlap, linePaintHalfStroke);
		widthOverlap = Math.max(widthOverlap, linePaintHalfStroke);

		float polygonPaintHalfStroke = this.scale * polygonStrokeWidth / 2.0f;
		heightOverlap = Math.max(heightOverlap, polygonPaintHalfStroke);
		widthOverlap = Math.max(widthOverlap, polygonPaintHalfStroke);

		if (featureTableStyles != null && featureTableStyles.has()) {

			// Style Rows
			Set<Long> styleRowIds = new HashSet<>();
			List<Long> tableStyleIds = featureTableStyles.getAllTableStyleIds();
			if (tableStyleIds != null) {
				styleRowIds.addAll(tableStyleIds);
			}
			List<Long> styleIds = featureTableStyles.getAllStyleIds();
			if (styleIds != null) {
				styleRowIds.addAll(styleIds);
			}

			StyleDao styleDao = featureTableStyles.getStyleDao();
			for (long styleRowId : styleRowIds) {
				StyleRow styleRow = styleDao
						.getRow(styleDao.queryForIdRow(styleRowId));
				float styleHalfWidth = this.scale
						* (float) (styleRow.getWidthOrDefault() / 2.0f);
				widthOverlap = Math.max(widthOverlap, styleHalfWidth);
				heightOverlap = Math.max(heightOverlap, styleHalfWidth);
			}

			// Icon Rows
			Set<Long> iconRowIds = new HashSet<>();
			List<Long> tableIconIds = featureTableStyles.getAllTableIconIds();
			if (tableIconIds != null) {
				iconRowIds.addAll(tableIconIds);
			}
			List<Long> iconIds = featureTableStyles.getAllIconIds();
			if (iconIds != null) {
				iconRowIds.addAll(iconIds);
			}

			IconDao iconDao = featureTableStyles.getIconDao();
			for (long iconRowId : iconRowIds) {
				IconRow iconRow = iconDao
						.getRow(iconDao.queryForIdRow(iconRowId));
				double[] iconDimensions = iconRow.getDerivedDimensions();
				float iconWidth = this.scale
						* (float) Math.ceil(iconDimensions[0]);
				float iconHeight = this.scale
						* (float) Math.ceil(iconDimensions[1]);
				widthOverlap = Math.max(widthOverlap, iconWidth);
				heightOverlap = Math.max(heightOverlap, iconHeight);
			}

		}

	}

	/**
	 * Set the scale
	 * 
	 * @param scale
	 *            scale factor
	 * @since 3.2.0
	 */
	public void setScale(float scale) {
		this.scale = scale;
		linePaint.setStrokeWidth(scale * lineStrokeWidth);
		polygonPaint.setStrokeWidth(scale * polygonStrokeWidth);
		featurePaintCache.clear();
	}

	/**
	 * Get the scale
	 * 
	 * @return scale factor
	 * @since 3.2.0
	 */
	public float getScale() {
		return scale;
	}

	/**
	 * Manually set the width and height draw overlap
	 *
	 * @param pixels
	 *            pixels
	 */
	public void setDrawOverlap(float pixels) {
		setWidthDrawOverlap(pixels);
		setHeightDrawOverlap(pixels);
	}

	/**
	 * Get the width draw overlap
	 *
	 * @return width draw overlap
	 */
	public float getWidthDrawOverlap() {
		return widthOverlap;
	}

	/**
	 * Manually set the width draw overlap
	 *
	 * @param pixels
	 *            pixels
	 */
	public void setWidthDrawOverlap(float pixels) {
		widthOverlap = pixels;
	}

	/**
	 * Get the height draw overlap
	 *
	 * @return height draw overlap
	 */
	public float getHeightDrawOverlap() {
		return heightOverlap;
	}

	/**
	 * Manually set the height draw overlap
	 *
	 * @param pixels
	 *            pixels
	 */
	public void setHeightDrawOverlap(float pixels) {
		heightOverlap = pixels;
	}

	/**
	 * Get the feature DAO
	 *
	 * @return feature dao
	 */
	public FeatureDao getFeatureDao() {
		return featureDao;
	}

	/**
	 * Is index query
	 *
	 * @return true if an index query
	 */
	public boolean isIndexQuery() {
		return featureIndex != null && featureIndex.isIndexed();
	}

	/**
	 * Get the feature index
	 *
	 * @return feature index or null
	 */
	public FeatureTableIndex getFeatureIndex() {
		return featureIndex;
	}

	/**
	 * Set the feature index
	 *
	 * @param featureIndex
	 *            feature index
	 */
	public void setFeatureIndex(FeatureTableIndex featureIndex) {
		this.featureIndex = featureIndex;
	}

	/**
	 * Get the feature table styles
	 *
	 * @return feature table styles
	 * @since 3.2.0
	 */
	public FeatureTableStyles getFeatureTableStyles() {
		return featureTableStyles;
	}

	/**
	 * Set the feature table styles
	 *
	 * @param featureTableStyles
	 *            feature table styles
	 * @since 3.2.0
	 */
	public void setFeatureTableStyles(FeatureTableStyles featureTableStyles) {
		this.featureTableStyles = featureTableStyles;
	}

	/**
	 * Ignore the feature table styles within the GeoPackage
	 * 
	 * @since 3.2.0
	 */
	public void ignoreFeatureTableStyles() {
		setFeatureTableStyles(null);
		calculateDrawOverlap();
	}

	/**
	 * Clear all caches
	 *
	 * @since 3.3.0
	 */
	public void clearCache() {
		clearStylePaintCache();
		clearIconCache();
	}

	/**
	 * Clear the style paint cache
	 *
	 * @since 3.3.0
	 */
	public void clearStylePaintCache() {
		featurePaintCache.clear();
	}

	/**
	 * Set / resize the style paint cache size
	 *
	 * @param size
	 *            new size
	 * @since 3.3.0
	 */
	public void setStylePaintCacheSize(int size) {
		featurePaintCache.resize(size);
	}

	/**
	 * Clear the icon cache
	 *
	 * @since 3.3.0
	 */
	public void clearIconCache() {
		iconCache.clear();
	}

	/**
	 * Set / resize the icon cache size
	 *
	 * @param size
	 *            new size
	 * @since 3.3.0
	 */
	public void setIconCacheSize(int size) {
		iconCache.resize(size);
	}

	/**
	 * Get the tile width
	 *
	 * @return tile width
	 */
	public int getTileWidth() {
		return tileWidth;
	}

	/**
	 * Set the tile width
	 *
	 * @param tileWidth
	 *            tile width
	 */
	public void setTileWidth(int tileWidth) {
		this.tileWidth = tileWidth;
	}

	/**
	 * Get the tile height
	 *
	 * @return tile height
	 */
	public int getTileHeight() {
		return tileHeight;
	}

	/**
	 * Set the tile height
	 *
	 * @param tileHeight
	 *            tile height
	 */
	public void setTileHeight(int tileHeight) {
		this.tileHeight = tileHeight;
	}

	/**
	 * Get the compress format
	 *
	 * @return compress format
	 */
	public String getCompressFormat() {
		return compressFormat;
	}

	/**
	 * Set the compress format
	 *
	 * @param compressFormat
	 *            compress format
	 */
	public void setCompressFormat(String compressFormat) {
		this.compressFormat = compressFormat;
	}

	/**
	 * Get the point radius
	 *
	 * @return radius
	 */
	public float getPointRadius() {
		return pointRadius;
	}

	/**
	 * Set the point radius
	 *
	 * @param pointRadius
	 *            point radius
	 */
	public void setPointRadius(float pointRadius) {
		this.pointRadius = pointRadius;
	}

	/**
	 * Get point color
	 * 
	 * @return color
	 */
	public Color getPointColor() {
		return pointPaint.getColor();
	}

	/**
	 * Set point color
	 * 
	 * @param pointColor
	 *            point color
	 */
	public void setPointColor(Color pointColor) {
		pointPaint.setColor(pointColor);
	}

	/**
	 * Get the point icon
	 *
	 * @return icon
	 */
	public FeatureTilePointIcon getPointIcon() {
		return pointIcon;
	}

	/**
	 * Set the point icon
	 *
	 * @param pointIcon
	 *            point icon
	 */
	public void setPointIcon(FeatureTilePointIcon pointIcon) {
		this.pointIcon = pointIcon;
	}

	/**
	 * Get line stroke width
	 * 
	 * @return width
	 */
	public float getLineStrokeWidth() {
		return lineStrokeWidth;
	}

	/**
	 * Set line stroke width
	 * 
	 * @param lineStrokeWidth
	 *            line stroke width
	 */
	public void setLineStrokeWidth(float lineStrokeWidth) {
		this.lineStrokeWidth = lineStrokeWidth;
		linePaint.setStrokeWidth(this.scale * lineStrokeWidth);
	}

	/**
	 * Get line color
	 * 
	 * @return color
	 */
	public Color getLineColor() {
		return linePaint.getColor();
	}

	/**
	 * Set line color
	 * 
	 * @param lineColor
	 *            line color
	 */
	public void setLineColor(Color lineColor) {
		linePaint.setColor(lineColor);
	}

	/**
	 * Get polygon stroke width
	 * 
	 * @return width
	 */
	public float getPolygonStrokeWidth() {
		return polygonStrokeWidth;
	}

	/**
	 * Set polygon stroke width
	 * 
	 * @param polygonStrokeWidth
	 *            polygon stroke width
	 */
	public void setPolygonStrokeWidth(float polygonStrokeWidth) {
		this.polygonStrokeWidth = polygonStrokeWidth;
		polygonPaint.setStrokeWidth(this.scale * polygonStrokeWidth);
	}

	/**
	 * Get polygon color
	 * 
	 * @return color
	 */
	public Color getPolygonColor() {
		return polygonPaint.getColor();
	}

	/**
	 * Set polygon color
	 * 
	 * @param polygonColor
	 *            polygon color
	 */
	public void setPolygonColor(Color polygonColor) {
		polygonPaint.setColor(polygonColor);
	}

	/**
	 * Is fill polygon
	 *
	 * @return true if fill polygon
	 */
	public boolean isFillPolygon() {
		return fillPolygon;
	}

	/**
	 * Set the fill polygon
	 *
	 * @param fillPolygon
	 *            fill polygon
	 */
	public void setFillPolygon(boolean fillPolygon) {
		this.fillPolygon = fillPolygon;
	}

	/**
	 * Get polygon fill color
	 * 
	 * @return color
	 */
	public Color getPolygonFillColor() {
		return polygonFillPaint.getColor();
	}

	/**
	 * Set polygon fill color
	 * 
	 * @param polygonFillColor
	 *            polygon fill color
	 */
	public void setPolygonFillColor(Color polygonFillColor) {
		polygonFillPaint.setColor(polygonFillColor);
	}

	/**
	 * Get the max features per tile
	 *
	 * @return max features per tile or null
	 */
	public Integer getMaxFeaturesPerTile() {
		return maxFeaturesPerTile;
	}

	/**
	 * Set the max features per tile. When more features are returned in a query
	 * to create a single tile, the tile is not created.
	 *
	 * @param maxFeaturesPerTile
	 *            max features per tile
	 */
	public void setMaxFeaturesPerTile(Integer maxFeaturesPerTile) {
		this.maxFeaturesPerTile = maxFeaturesPerTile;
	}

	/**
	 * Get the max features tile draw, the custom tile drawing implementation
	 * for tiles with more features than the max at #getMaxFeaturesPerTile
	 *
	 * @return max features tile draw or null
	 * @see CustomFeaturesTile
	 * @see mil.nga.geopackage.tiles.features.custom.NumberFeaturesTile custom
	 *      features tile implementation
	 */
	public CustomFeaturesTile getMaxFeaturesTileDraw() {
		return maxFeaturesTileDraw;
	}

	/**
	 * Set the max features tile draw, used to draw tiles when more features for
	 * a single tile than the max at #getMaxFeaturesPerTile exist
	 *
	 * @param maxFeaturesTileDraw
	 *            max features tile draw
	 * @see CustomFeaturesTile
	 * @see mil.nga.geopackage.tiles.features.custom.NumberFeaturesTile custom
	 *      features tile implementation
	 */
	public void setMaxFeaturesTileDraw(CustomFeaturesTile maxFeaturesTileDraw) {
		this.maxFeaturesTileDraw = maxFeaturesTileDraw;
	}

	/**
	 * Is the simplify geometries flag set? Default is true
	 * 
	 * @return simplify geometries flag
	 * @since 2.0.0
	 */
	public boolean isSimplifyGeometries() {
		return simplifyGeometries;
	}

	/**
	 * Set the simplify geometries flag
	 * 
	 * @param simplifyGeometries
	 *            simplify geometries flag
	 * @since 2.0.0
	 */
	public void setSimplifyGeometries(boolean simplifyGeometries) {
		this.simplifyGeometries = simplifyGeometries;
	}

	/**
	 * Draw the tile and get the bytes from the x, y, and zoom level
	 *
	 * @param x
	 *            x coordinate
	 * @param y
	 *            y coordinate
	 * @param zoom
	 *            zoom level
	 * @return tile bytes, or null
	 */
	public byte[] drawTileBytes(int x, int y, int zoom) {

		BufferedImage image = drawTile(x, y, zoom);

		byte[] tileData = null;

		// Convert the image to bytes
		if (image != null) {
			try {
				tileData = ImageUtils.writeImageToBytes(image, compressFormat);
			} catch (IOException e) {
				LOGGER.log(Level.SEVERE, "Failed to create tile. x: " + x
						+ ", y: " + y + ", zoom: " + zoom, e);
			}
		}

		return tileData;
	}

	/**
	 * Draw a tile image from the x, y, and zoom level
	 *
	 * @param x
	 *            x coordinate
	 * @param y
	 *            y coordinate
	 * @param zoom
	 *            zoom level
	 * @return tile image, or null
	 */
	public BufferedImage drawTile(int x, int y, int zoom) {
		BufferedImage image;
		if (isIndexQuery()) {
			image = drawTileQueryIndex(x, y, zoom);
		} else {
			image = drawTileQueryAll(x, y, zoom);
		}
		return image;
	}

	/**
	 * Draw a tile image from the x, y, and zoom level by querying features in
	 * the tile location
	 *
	 * @param x
	 *            x coordinate
	 * @param y
	 *            y coordinate
	 * @param zoom
	 *            zoom level
	 * @return drawn image, or null
	 */
	public BufferedImage drawTileQueryIndex(int x, int y, int zoom) {

		// Get the web mercator bounding box
		BoundingBox webMercatorBoundingBox = TileBoundingBoxUtils
				.getWebMercatorBoundingBox(x, y, zoom);

		BufferedImage image = null;

		// Query for the geometry count matching the bounds in the index
		long tileCount = queryIndexedFeaturesCount(webMercatorBoundingBox);

		// Draw if at least one geometry exists
		if (tileCount > 0) {

			// Query for geometries matching the bounds in the index
			CloseableIterator<GeometryIndex> results = queryIndexedFeatures(
					webMercatorBoundingBox);

			try {

				if (maxFeaturesPerTile == null
						|| tileCount <= maxFeaturesPerTile.longValue()) {

					// Draw the tile image
					image = drawTile(zoom, webMercatorBoundingBox, results);

				} else if (maxFeaturesTileDraw != null) {

					// Draw the max features tile
					image = maxFeaturesTileDraw.drawTile(tileWidth, tileHeight,
							tileCount, results);
				}
			} finally {
				try {
					results.close();
				} catch (IOException e) {
					LOGGER.log(Level.WARNING,
							"Failed to close result set for query on x: " + x
									+ ", y: " + y + ", zoom: " + zoom,
							e);
				}
			}
		}

		return image;
	}

	/**
	 * Query for feature result count in the x, y, and zoom
	 *
	 * @param x
	 *            x coordinate
	 * @param y
	 *            y coordinate
	 * @param zoom
	 *            zoom level
	 * @return feature count
	 */
	public long queryIndexedFeaturesCount(int x, int y, int zoom) {

		// Get the web mercator bounding box
		BoundingBox webMercatorBoundingBox = TileBoundingBoxUtils
				.getWebMercatorBoundingBox(x, y, zoom);

		// Query for the count of geometries matching the bounds in the index
		long count = queryIndexedFeaturesCount(webMercatorBoundingBox);

		return count;
	}

	/**
	 * Query for feature result count in the bounding box
	 * 
	 * @param webMercatorBoundingBox
	 *            web mercator bounding box
	 * @return count
	 */
	public long queryIndexedFeaturesCount(BoundingBox webMercatorBoundingBox) {

		// Create an expanded bounding box to handle features outside the tile
		// that overlap
		BoundingBox expandedQueryBoundingBox = expandBoundingBox(
				webMercatorBoundingBox);

		// Query for the count of geometries matching the bounds in the index
		long count = featureIndex.count(expandedQueryBoundingBox,
				WEB_MERCATOR_PROJECTION);

		return count;
	}

	/**
	 * Query for feature results in the x, y, and zoom
	 *
	 * @param x
	 *            x coordinate
	 * @param y
	 *            y coordinate
	 * @param zoom
	 *            zoom level
	 * @return feature count
	 * @since 3.2.0
	 */
	public CloseableIterator<GeometryIndex> queryIndexedFeatures(int x, int y,
			int zoom) {

		// Get the web mercator bounding box
		BoundingBox webMercatorBoundingBox = TileBoundingBoxUtils
				.getWebMercatorBoundingBox(x, y, zoom);

		// Query for the geometries matching the bounds in the index
		return queryIndexedFeatures(webMercatorBoundingBox);
	}

	/**
	 * Query for feature results in the bounding box
	 *
	 * @param webMercatorBoundingBox
	 *            web mercator bounding box
	 * @return geometry index results
	 */
	public CloseableIterator<GeometryIndex> queryIndexedFeatures(
			BoundingBox webMercatorBoundingBox) {

		// Create an expanded bounding box to handle features outside the tile
		// that overlap
		BoundingBox expandedQueryBoundingBox = expandBoundingBox(
				webMercatorBoundingBox);

		// Query for geometries matching the bounds in the index
		CloseableIterator<GeometryIndex> results = featureIndex
				.query(expandedQueryBoundingBox, WEB_MERCATOR_PROJECTION);

		return results;
	}

	/**
	 * Create an expanded bounding box to handle features outside the tile that
	 * overlap
	 * 
	 * @param boundingBox
	 *            bounding box
	 * @param projection
	 *            bounding box projection
	 * @return bounding box
	 * @since 3.2.0
	 */
	public BoundingBox expandBoundingBox(BoundingBox boundingBox,
			Projection projection) {

		BoundingBox expandedBoundingBox = boundingBox;

		ProjectionTransform toWebMercator = projection
				.getTransformation(ProjectionConstants.EPSG_WEB_MERCATOR);
		if (!toWebMercator.isSameProjection()) {
			expandedBoundingBox = expandedBoundingBox.transform(toWebMercator);
		}

		expandedBoundingBox = expandBoundingBox(expandedBoundingBox);

		if (!toWebMercator.isSameProjection()) {
			ProjectionTransform fromWebMercator = toWebMercator
					.getInverseTransformation();
			expandedBoundingBox = expandedBoundingBox
					.transform(fromWebMercator);
		}

		return expandedBoundingBox;
	}

	/**
	 * Create an expanded bounding box to handle features outside the tile that
	 * overlap
	 *
	 * @param webMercatorBoundingBox
	 *            web mercator bounding box
	 * @return bounding box
	 * @since 3.2.0
	 */
	public BoundingBox expandBoundingBox(BoundingBox webMercatorBoundingBox) {
		return expandBoundingBox(webMercatorBoundingBox,
				webMercatorBoundingBox);
	}

	/**
	 * Create an expanded bounding box to handle features outside the tile that
	 * overlap
	 *
	 * @param webMercatorBoundingBox
	 *            web mercator bounding box
	 * @param tileWebMercatorBoundingBox
	 *            tile web mercator bounding box
	 * @return bounding box
	 * @since 3.2.0
	 */
	public BoundingBox expandBoundingBox(BoundingBox webMercatorBoundingBox,
			BoundingBox tileWebMercatorBoundingBox) {

		// Create an expanded bounding box to handle features outside the tile
		// that overlap
		double minLongitude = TileBoundingBoxUtils.getLongitudeFromPixel(
				tileWidth, webMercatorBoundingBox, tileWebMercatorBoundingBox,
				0 - widthOverlap);
		double maxLongitude = TileBoundingBoxUtils.getLongitudeFromPixel(
				tileWidth, webMercatorBoundingBox, tileWebMercatorBoundingBox,
				tileWidth + widthOverlap);
		double maxLatitude = TileBoundingBoxUtils.getLatitudeFromPixel(
				tileHeight, webMercatorBoundingBox, tileWebMercatorBoundingBox,
				0 - heightOverlap);
		double minLatitude = TileBoundingBoxUtils.getLatitudeFromPixel(
				tileHeight, webMercatorBoundingBox, tileWebMercatorBoundingBox,
				tileHeight + heightOverlap);

		// Choose the most expanded longitudes and latitudes
		minLongitude = Math.min(minLongitude,
				webMercatorBoundingBox.getMinLongitude());
		maxLongitude = Math.max(maxLongitude,
				webMercatorBoundingBox.getMaxLongitude());
		minLatitude = Math.min(minLatitude,
				webMercatorBoundingBox.getMinLatitude());
		maxLatitude = Math.max(maxLatitude,
				webMercatorBoundingBox.getMaxLatitude());

		BoundingBox expandedBoundingBox = new BoundingBox(minLongitude,
				minLatitude, maxLongitude, maxLatitude);

		// Bound with the web mercator limits
		expandedBoundingBox = TileBoundingBoxUtils
				.boundWebMercatorBoundingBox(expandedBoundingBox);

		return expandedBoundingBox;
	}

	/**
	 * Draw a tile image from the x, y, and zoom level by querying all features.
	 * This could be very slow if there are a lot of features
	 *
	 * @param x
	 *            x coordinate
	 * @param y
	 *            y coordinate
	 * @param zoom
	 *            zoom level
	 * @return drawn image, or null
	 */
	public BufferedImage drawTileQueryAll(int x, int y, int zoom) {

		BoundingBox boundingBox = TileBoundingBoxUtils
				.getWebMercatorBoundingBox(x, y, zoom);

		BufferedImage image = null;

		// Query for all features
		FeatureResultSet resultSet = featureDao.queryForAll();

		try {

			int totalCount = resultSet.getCount();

			// Draw if at least one geometry exists
			if (totalCount > 0) {

				if (maxFeaturesPerTile == null
						|| totalCount <= maxFeaturesPerTile) {

					// Draw the tile image
					image = drawTile(zoom, boundingBox, resultSet);

				} else if (maxFeaturesTileDraw != null) {

					// Draw the unindexed max features tile
					image = maxFeaturesTileDraw.drawUnindexedTile(tileWidth,
							tileHeight, totalCount, resultSet);
				}

			}
		} finally {
			resultSet.close();
		}

		return image;
	}

	/**
	 * Create a new empty image
	 *
	 * @return image
	 */
	protected BufferedImage createNewImage() {
		return new BufferedImage(tileWidth, tileHeight,
				BufferedImage.TYPE_INT_ARGB);
	}

	/**
	 * Get a graphics for the image
	 * 
	 * @param image
	 *            buffered image
	 * @return graphics
	 */
	protected Graphics2D getGraphics(BufferedImage image) {
		Graphics2D graphics = image.createGraphics();
		graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
				RenderingHints.VALUE_ANTIALIAS_ON);
		return graphics;
	}

	/**
	 * Create a projection transformation from the feature dao projection to Web
	 * Mercator
	 *
	 * @return transform
	 */
	protected ProjectionTransform getWebMercatorTransform() {
		return this.featureDao.getProjection()
				.getTransformation(ProjectionConstants.EPSG_WEB_MERCATOR);
	}

	/**
	 * When the simplify tolerance is set, simplify the points to a similar
	 * curve with fewer points.
	 * 
	 * @param simplifyTolerance
	 *            simplify tolerance in meters
	 * @param points
	 *            ordered points
	 * @return simplified points
	 * @since 2.0.0
	 */
	protected List<Point> simplifyPoints(double simplifyTolerance,
			List<Point> points) {

		List<Point> simplifiedPoints = null;
		if (simplifyGeometries) {

			// Reproject to web mercator if not in meters
			if (projection != null && !projection.isUnit(Units.METRES)) {
				ProjectionTransform toWebMercator = projection
						.getTransformation(WEB_MERCATOR_PROJECTION);
				points = toWebMercator.transform(points);
			}

			// Simplify the points
			simplifiedPoints = GeometryUtils.simplifyPoints(points,
					simplifyTolerance);

			// Reproject back to the original projection
			if (projection != null && !projection.isUnit(Units.METRES)) {
				ProjectionTransform fromWebMercator = WEB_MERCATOR_PROJECTION
						.getTransformation(projection);
				simplifiedPoints = fromWebMercator.transform(simplifiedPoints);
			}
		} else {
			simplifiedPoints = points;
		}

		return simplifiedPoints;
	}

	/**
	 * Get the feature style for the feature row and geometry type
	 *
	 * @param featureRow
	 *            feature row
	 * @return feature style
	 */
	protected FeatureStyle getFeatureStyle(FeatureRow featureRow) {
		FeatureStyle featureStyle = null;
		if (featureTableStyles != null) {
			featureStyle = featureTableStyles.getFeatureStyle(featureRow);
		}
		return featureStyle;
	}

	/**
	 * Get the feature style for the feature row and geometry type
	 *
	 * @param featureRow
	 *            feature row
	 * @param geometryType
	 *            geometry type
	 * @return feature style
	 */
	protected FeatureStyle getFeatureStyle(FeatureRow featureRow,
			GeometryType geometryType) {
		FeatureStyle featureStyle = null;
		if (featureTableStyles != null) {
			featureStyle = featureTableStyles.getFeatureStyle(featureRow,
					geometryType);
		}
		return featureStyle;
	}

	/**
	 * Get the icon image from the icon row
	 *
	 * @param iconRow
	 *            icon row
	 * @return icon image
	 */
	protected BufferedImage getIcon(IconRow iconRow) {
		return iconCache.createIcon(iconRow, scale);
	}

	/**
	 * Get the point paint for the feature style, or return the default paint
	 *
	 * @param featureStyle
	 *            feature style
	 * @return paint
	 */
	protected Paint getPointPaint(FeatureStyle featureStyle) {

		Paint paint = getFeatureStylePaint(featureStyle,
				FeatureDrawType.CIRCLE);

		if (paint == null) {
			paint = pointPaint;
		}

		return paint;
	}

	/**
	 * Get the line paint for the feature style, or return the default paint
	 *
	 * @param featureStyle
	 *            feature style
	 * @return paint
	 */
	protected Paint getLinePaint(FeatureStyle featureStyle) {

		Paint paint = getFeatureStylePaint(featureStyle,
				FeatureDrawType.STROKE);

		if (paint == null) {
			paint = linePaint;
		}

		return paint;
	}

	/**
	 * Get the polygon paint for the feature style, or return the default paint
	 *
	 * @param featureStyle
	 *            feature style
	 * @return paint
	 */
	protected Paint getPolygonPaint(FeatureStyle featureStyle) {

		Paint paint = getFeatureStylePaint(featureStyle,
				FeatureDrawType.STROKE);

		if (paint == null) {
			paint = polygonPaint;
		}

		return paint;
	}

	/**
	 * Get the polygon fill paint for the feature style, or return the default
	 * paint
	 *
	 * @param featureStyle
	 *            feature style
	 * @return paint
	 */
	protected Paint getPolygonFillPaint(FeatureStyle featureStyle) {

		Paint paint = null;

		boolean hasStyleColor = false;

		if (featureStyle != null) {

			StyleRow style = featureStyle.getStyle();

			if (style != null) {

				if (style.hasFillColor()) {
					paint = getStylePaint(style, FeatureDrawType.FILL);
				} else {
					hasStyleColor = style.hasColor();
				}

			}

		}

		if (paint == null && !hasStyleColor && fillPolygon) {
			paint = polygonFillPaint;
		}

		return paint;
	}

	/**
	 * Get the feature style paint from cache, or create and cache it
	 *
	 * @param featureStyle
	 *            feature style
	 * @param drawType
	 *            draw type
	 * @return feature style paint
	 */
	private Paint getFeatureStylePaint(FeatureStyle featureStyle,
			FeatureDrawType drawType) {

		Paint paint = null;

		if (featureStyle != null) {

			StyleRow style = featureStyle.getStyle();

			if (style != null && style.hasColor()) {

				paint = getStylePaint(style, drawType);

			}
		}

		return paint;
	}

	/**
	 * Get the style paint from cache, or create and cache it
	 *
	 * @param style
	 *            style row
	 * @param drawType
	 *            draw type
	 * @return paint
	 */
	private Paint getStylePaint(StyleRow style, FeatureDrawType drawType) {

		Paint paint = featurePaintCache.getPaint(style, drawType);

		if (paint == null) {

			mil.nga.geopackage.style.Color color = null;
			Float strokeWidth = null;

			switch (drawType) {
			case CIRCLE:
				color = style.getColorOrDefault();
				break;
			case STROKE:
				color = style.getColorOrDefault();
				strokeWidth = this.scale * (float) style.getWidthOrDefault();
				break;
			case FILL:
				color = style.getFillColor();
				strokeWidth = this.scale * (float) style.getWidthOrDefault();
				break;
			default:
				throw new GeoPackageException(
						"Unsupported Draw Type: " + drawType);
			}

			Paint stylePaint = new Paint();
			stylePaint.setColor(new Color(color.getColorWithAlpha(), true));
			if (strokeWidth != null) {
				stylePaint.setStrokeWidth(strokeWidth);
			}

			synchronized (featurePaintCache) {

				paint = featurePaintCache.getPaint(style, drawType);

				if (paint == null) {
					featurePaintCache.setPaint(style, drawType, stylePaint);
					paint = stylePaint;
				}

			}
		}

		return paint;
	}

	/**
	 * Determine if the image is transparent
	 *
	 * @param image
	 *            image
	 * @return true if transparent
	 */
	protected boolean isTransparent(BufferedImage image) {
		boolean transparent = false;
		if (image != null) {
			WritableRaster raster = image.getAlphaRaster();
			if (raster != null) {
				transparent = true;
				done: for (int x = 0; x < image.getWidth(); x++) {
					for (int y = 0; y < image.getHeight(); y++) {
						if (raster.getSample(x, y, 0) > 0) {
							transparent = false;
							break done;
						}
					}
				}
			}
		}
		return transparent;
	}

	/**
	 * Check if the image was drawn upon (non null and not transparent). Return
	 * the same image if drawn, else return null
	 *
	 * @param image
	 *            image
	 * @return drawn image or null
	 */
	protected BufferedImage checkIfDrawn(BufferedImage image) {
		if (isTransparent(image)) {
			image = null;
		}
		return image;
	}

	/**
	 * Draw a tile image from geometry index results
	 *
	 * @param zoom
	 *            zoom level
	 * @param webMercatorBoundingBox
	 *            web mercator bounding box
	 * @param results
	 *            results
	 * @return image
	 * @since 2.0.0
	 */
	public abstract BufferedImage drawTile(int zoom,
			BoundingBox webMercatorBoundingBox,
			CloseableIterator<GeometryIndex> results);

	/**
	 * Draw a tile image from feature geometries in the provided cursor
	 *
	 * @param zoom
	 *            zoom level
	 * @param webMercatorBoundingBox
	 *            web mercator bounding box
	 * @param resultSet
	 *            feature result set
	 * @return image
	 * @since 2.0.0
	 */
	public abstract BufferedImage drawTile(int zoom,
			BoundingBox webMercatorBoundingBox, FeatureResultSet resultSet);

	/**
	 * Draw a tile image from the feature rows
	 *
	 * @param zoom
	 *            zoom level
	 * @param webMercatorBoundingBox
	 *            web mercator bounding box
	 * @param featureRow
	 *            feature row
	 * @return image
	 * @since 2.0.0
	 */
	public abstract BufferedImage drawTile(int zoom,
			BoundingBox webMercatorBoundingBox, List<FeatureRow> featureRow);

}