/*
 *    GeoTools - OpenSource mapping toolkit
 *    http://geotools.org
 *    (C) 2002-2006, GeoTools Project Managment Committee (PMC)
 * 
 *    This library 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;
 *    version 2.1 of the License.
 *
 *    This library 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.
 */
package org.geotools.filter;

import java.io.IOException;
import java.sql.Date;
import java.util.logging.Logger;

import org.geotools.filter.FilterType;
import org.opengis.filter.ExcludeFilter;
import org.opengis.filter.Id;
import org.opengis.filter.IncludeFilter;
import org.opengis.filter.PropertyIsBetween;
import org.opengis.filter.PropertyIsLike;
import org.opengis.filter.PropertyIsNull;
import org.opengis.filter.expression.Add;
import org.opengis.filter.expression.Divide;
import org.opengis.filter.expression.Multiply;
import org.opengis.filter.expression.Subtract;
import org.opengis.filter.spatial.BBOX;
import org.opengis.filter.spatial.Contains;
import org.opengis.filter.spatial.Crosses;
import org.opengis.filter.spatial.Disjoint;
import org.opengis.filter.spatial.Equals;
import org.opengis.filter.spatial.Intersects;
import org.opengis.filter.spatial.Overlaps;
import org.opengis.filter.spatial.Touches;
import org.opengis.filter.spatial.Within;

import com.vividsolutions.jts.geom.Geometry;
import com.vividsolutions.jts.geom.LineString;
import com.vividsolutions.jts.geom.LinearRing;
import com.vividsolutions.jts.io.WKTWriter;


/**
 * Encodes a filter into a SQL WHERE statement for postgis.  This class adds
 * the ability to turn geometry filters into sql statements if they are
 * bboxes.
 *
 * @author Chris Holmes, TOPP
 *
 * @task TODO: integrated with SQLEncoderPostgisGeos.java, as there no  real
 *       reason to have two different classes.  We just need to do testing to
 *       make sure both handle everything.  At the very least have the geos
 *       one extend more intelligently.
 * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/plugin/postgis/src/main/java/org/geotools/filter/SQLEncoderPostgis.java $
 */
public class SQLEncoderPostgis extends SQLEncoder implements
        org.geotools.filter.FilterVisitor {
    /** Standard java logger */
    private static Logger LOGGER = org.geotools.util.logging.Logging.getLogger("org.geotools.filter");

    /** To write geometry so postgis can read it. */
    private static WKTWriter wkt = new WKTWriter();

    /**
     * The srid of the schema, so the bbox conforms.  Could be better to have
     * it in the bbox filter itself, but this works for now.
     */
    private int srid;

    /** The geometry attribute to use if none is specified. */
    private String defaultGeom;
    
    /** Whether the BBOX filter should be strict (using the exact geom), or 
     *  loose (using the envelopes) */
    protected boolean looseBbox = false;

    /**
     * Whether the installed PostGIS has GEOS support. Default is false for
     * backwards compatibility.
     */
    protected boolean supportsGEOS = false;
   
    /**
     * Empty constructor TODO: rethink empty constructor, as BBOXes _need_ an
     * SRID, must make client set it somehow.  Maybe detect when encode is
     * called?
     *
     */
    public SQLEncoderPostgis() {
        capabilities = createFilterCapabilities();
        setSqlNameEscape("\"");
    }

    public SQLEncoderPostgis(boolean looseBbox) {
        this();
        this.looseBbox = looseBbox;
    }
    

    /**
     * 
     * @see org.geotools.filter.SQLEncoder#createFilterCapabilities()
     */
    protected FilterCapabilities createFilterCapabilities() {
        FilterCapabilities capabilities = new FilterCapabilities();

        capabilities.addType(FilterCapabilities.NONE);
        capabilities.addType(IncludeFilter.class);
        capabilities.addType(FilterCapabilities.ALL);
        capabilities.addType(ExcludeFilter.class);
        capabilities.addType(FilterCapabilities.FID);
        capabilities.addType(Id.class);
        capabilities.addType(FilterCapabilities.NULL_CHECK);
        capabilities.addType(PropertyIsNull.class);
        capabilities.addType(FilterCapabilities.BETWEEN);
        capabilities.addType(PropertyIsBetween.class);
        capabilities.addType(FilterCapabilities.LOGICAL);
        capabilities.addAll(FilterCapabilities.LOGICAL_OPENGIS);
        capabilities.addType(FilterCapabilities.SIMPLE_ARITHMETIC);
        capabilities.addType(Add.class);
        capabilities.addType(Multiply.class);
        capabilities.addType(Subtract.class);
        capabilities.addType(Divide.class);
        capabilities.addType(FilterCapabilities.SIMPLE_COMPARISONS);
        capabilities.addAll(FilterCapabilities.SIMPLE_COMPARISONS_OPENGIS);
        capabilities.addType(FilterCapabilities.SPATIAL_BBOX);
        capabilities.addType(BBOX.class);
        capabilities.addType(FilterCapabilities.LIKE);
        capabilities.addType(PropertyIsLike.class);

        if (supportsGEOS) {
            capabilities.addType(FilterCapabilities.SPATIAL_CONTAINS);
            capabilities.addType(Contains.class);
            capabilities.addType(FilterCapabilities.SPATIAL_CROSSES);
            capabilities.addType(Crosses.class);
            capabilities.addType(FilterCapabilities.SPATIAL_DISJOINT);
            capabilities.addType(Disjoint.class);
            capabilities.addType(FilterCapabilities.SPATIAL_EQUALS);
            capabilities.addType(Equals.class);
            capabilities.addType(FilterCapabilities.SPATIAL_INTERSECT);
            capabilities.addType(Intersects.class);
            capabilities.addType(FilterCapabilities.SPATIAL_OVERLAPS);
            capabilities.addType(Overlaps.class);
            capabilities.addType(FilterCapabilities.SPATIAL_TOUCHES);
            capabilities.addType(Touches.class);
            capabilities.addType(FilterCapabilities.SPATIAL_WITHIN);
            capabilities.addType(Within.class);
        }
        
        // TODO: add SPATIAL_BEYOND, DWITHIN to capabilities and support in
        // visit(GeometryFilter)
        
        return capabilities;
    }
    
    

    /**
     * Constructor with srid.
     *
     * @param srid spatial reference id to encode geometries with.
     */
    public SQLEncoderPostgis(int srid) {
        this(true);
        this.srid = srid;
    }

    /**
     * Sets whether the Filter.BBOX query should be 'loose', meaning that it
     * should just doing a bounding box against the envelope.  If set to
     * <tt>false</tt> then the BBOX query will perform a full intersects 
     * against the geometry, ensuring that it is exactly correct.  If 
     * <tt>true</tt> then the query will likely perform faster, but may not
     * be exactly correct.
     *
     * @param isLooseBbox whether the bbox should be loose or strict.
     */
    public void setLooseBbox(boolean isLooseBbox) {
	this.looseBbox = isLooseBbox;
    }
   
    /**
     * Gets whether the Filter.BBOX query will be strict and use an intersects
     * or 'loose' and just operate against the geometry envelopes.
     *
     * @return <tt>true</tt> if this encoder is going to do loose filtering.
     */
    public boolean isLooseBbox() {
        return looseBbox;
    }
    


    /**
     * Sets a spatial reference system ESPG number, so that the geometry can be
     * properly encoded for postgis.  If geotools starts actually creating
     * geometries with valid srids then this method will no longer be needed.
     *
     * @param srid the integer code for the EPSG spatial reference system.
     */
    public void setSRID(int srid) {
        this.srid = srid;
    }

    /**
     * Sets the default geometry, so that filters with null for one of their
     * expressions can assume that the default geometry is intended.
     *
     * @param name the name of the default geometry Attribute.
     *
     * @task REVISIT: pass in a featureType so that geometries can figure out
     *       their own default geometry?
     */
    public void setDefaultGeometry(String name) {
        //Do we really want clients to be using malformed filters?  
        //I mean, this is a useful method for unit tests, but shouldn't 
        //fully formed filters usually be used?  Though I guess adding 
        //the option wouldn't hurt. -ch
        this.defaultGeom = name;
    }

    public void setSupportsGEOS(boolean supports) {
        boolean oldValue = this.supportsGEOS;
        this.supportsGEOS = supports;
        if (capabilities == null || supports != oldValue) {
            //regenerate capabilities
            capabilities = createFilterCapabilities();
        }
    }
    
    public boolean getSupportsGEOS() {
        return supportsGEOS;
    }
    
    private void encodeGeomFilter(GeometryFilter filter, String function, String comparison, boolean useIndex) {
        //this method blindly assumes that the filter is supported
        DefaultExpression left = (DefaultExpression) filter.getLeftGeometry();
        DefaultExpression right = (DefaultExpression) filter.getRightGeometry();

        try {
            //should we use the index?
            if (useIndex) {
                encodeExpression(left);
                out.write(" && ");
                encodeExpression(right);
            }
            
            // looseBbox only applies to GEOMETRY_BBOX, so unless this is a
            // BBOX, we will always generate the full SQL.
            if (filter.getFilterType() != AbstractFilter.GEOMETRY_BBOX || !looseBbox) {
                if (useIndex) {
                    out.write(" AND ");
                }
            	out.write(function + "(");
                encodeExpression(left);
                out.write(", ");
                encodeExpression(right);
                out.write(")" + comparison);
            }
        } catch (java.io.IOException ioe) {
            LOGGER.warning("Unable to export filter" + ioe);
        }
    }

    private void encodeExpression(DefaultExpression expr) throws IOException {
        if (expr == null) {
            out.write("\"" + defaultGeom + "\"");
        } else {
            expr.accept(this);
        }
    }
    
    /**
     * Turns a geometry filter into the postgis sql bbox statement.
     *
     * @param filter the geometry filter to be encoded.
     *
     * @throws RuntimeException for IO exception (need a better error)
     */
    public void visit(GeometryFilter filter) throws RuntimeException {
        LOGGER.finer("exporting GeometryFilter");

        short filterType = filter.getFilterType();
        DefaultExpression left = (DefaultExpression) filter.getLeftGeometry();
        DefaultExpression right = (DefaultExpression) filter.getRightGeometry();

        //if geos is not supported, all we can use is distance = 0 for bbox
        if (!supportsGEOS) {
            if (filterType != AbstractFilter.GEOMETRY_BBOX) {
                throw new RuntimeException(
                        "without GEOS support, only the BBOX function is supported; failed to encode "
                                + filterType);
            }
            encodeGeomFilter(filter, "distance", " < 0.00001", true);
            return;
        }
        
        // Figure out if we need to constrain this query with the && constraint.
        int literalGeometryCount = 0;

        if ((left != null)
                && (left.getType() == DefaultExpression.LITERAL_GEOMETRY)) {
            literalGeometryCount++;
        }

        if ((right != null)
                && (right.getType() == DefaultExpression.LITERAL_GEOMETRY)) {
            literalGeometryCount++;
        }

        boolean constrainBBOX = (literalGeometryCount == 1);
        boolean onlyBbox = filterType == AbstractFilter.GEOMETRY_BBOX
                && looseBbox;

        try {

            // DJB: disjoint is not correctly handled in the pre-march 22/05
            // version
            // I changed it to not do a "&&" index search for disjoint because
            // Geom1 and Geom2 can have a bbox overlap and be disjoint
            // I also added test case.
            // NOTE: this will not use the index, but its unlikely that using
            // the index
            // for a disjoint query will be the correct thing to do.

            // DJB NOTE: need to check for a NOT(A intersects G) filter
            // --> NOT( (A && G) AND intersects(A,G))
            // and check that it does the right thing.

            constrainBBOX = constrainBBOX
                    && (filterType != AbstractFilter.GEOMETRY_DISJOINT);

            if (constrainBBOX) {
                encodeExpression(left);
                out.write(" && ");
                encodeExpression(right);

                if (!onlyBbox) {
                    out.write(" AND ");
                }
            }

            String closingParenthesis = ")";

            if (!onlyBbox) {
                if (filterType == AbstractFilter.GEOMETRY_EQUALS) {
                    out.write("equals");
                } else if (filterType == AbstractFilter.GEOMETRY_DISJOINT) {
                    out.write("NOT (intersects");
                    closingParenthesis += ")";
                } else if (filterType == AbstractFilter.GEOMETRY_INTERSECTS) {
                    out.write("intersects");
                } else if (filterType == AbstractFilter.GEOMETRY_CROSSES) {
                    out.write("crosses");
                } else if (filterType == AbstractFilter.GEOMETRY_WITHIN) {
                    out.write("within");
                } else if (filterType == AbstractFilter.GEOMETRY_CONTAINS) {
                    out.write("contains");
                } else if (filterType == AbstractFilter.GEOMETRY_OVERLAPS) {
                    out.write("overlaps");
                } else if (filterType == AbstractFilter.GEOMETRY_BBOX) {
                    out.write("intersects");
                } else if (filterType == AbstractFilter.GEOMETRY_TOUCHES) {
                    out.write("touches");
                } else {
                    // this will choke on beyond and dwithin
                    throw new RuntimeException("does not support filter type "
                            + filterType);
                }
                out.write("(");

                encodeExpression(left);
                out.write(", ");
                encodeExpression(right);

                out.write(closingParenthesis);
            }
        } catch (java.io.IOException ioe) {
            LOGGER.warning("Unable to export filter" + ioe);
            throw new RuntimeException("io error while writing", ioe);
        }
    }

    
    /**
     * Checks to see if the literal is a geometry, and encodes it if it is, if
     * not just sends to the parent class.
     * 
     * @param expression
     *            the expression to visit and encode.
     * 
     * @throws IOException
     *             for IO exception (need a better error)
     */
    public void visitLiteralGeometry(LiteralExpression expression)
        throws IOException {
        Geometry bbox = (Geometry) expression.evaluate( null, Geometry.class );
        String geomText = null;
        if ( bbox instanceof LinearRing ) {
        	//postgis does not handle linear rings, convert to just a line string
        	LineString lineString = new LineString( 
    			((LinearRing)bbox).getCoordinateSequence(), bbox.getFactory()
			);
        	geomText = wkt.write( lineString );
        }
        else {
        	geomText = wkt.write(bbox);	
        }
        
        out.write("GeometryFromText('" + geomText + "', " + srid + ")");
    }

    public void visit(LikeFilter filter) throws UnsupportedOperationException {
    	char esc = filter.getEscape().charAt(0);
    	char multi = filter.getWildcardMulti().charAt(0);
    	char single = filter.getWildcardSingle().charAt(0);
    	String pattern = LikeFilterImpl.convertToSQL92(esc,multi,single,filter.getPattern());
    	
    	DefaultExpression att = (DefaultExpression) filter.getValue();
    	
    	try {
    		out.write( " ( " );
	    	att.accept(this);
	    	
	    	out.write(" LIKE '");
	    	out.write(pattern);
	    	out.write("' ");	
	    	
	    	//JD: this is an ugly ugly hack!! hopefully when the new feature model is around we can 
	    	// fix this
	    	//check for context for a date
	    	if ( att instanceof AttributeExpression && context != null 
	    			&& java.util.Date.class.isAssignableFrom( context ) ) {
	    		//if it is a date, add additional logic for a timestamp, or a timestamp with 
	    		// timezone
	    		out.write( " OR " );
	    		att.accept( this );
	    		out.write( " LIKE '" );
	    		out.write(pattern + " __:__:__'" );	//timestamp
	    		
	    		out.write( " OR " );
	    		att.accept( this );
	    		out.write( " LIKE '" );
	    		out.write(pattern + " __:__:_____'" );	//timestamp with time zone
		    }
	    	
	    	out.write( " ) " );
	    	
    	} catch (java.io.IOException ioe) {
            throw new RuntimeException(IO_ERROR, ioe);
        }    	
    	
    	
    }
    
    /**
     * Checks to see if the literal is a geometry, and encodes it if it  is, if
     * not just sends to the parent class.
     *
     * @param expression the expression to visit and encode.
     *
     * @throws RuntimeException for IO exception (need a better error)
     */
    public void visit(LiteralExpression expression) throws RuntimeException {
        LOGGER.finer("exporting LiteralExpression");

        try {
            if (expression.getType() == DefaultExpression.LITERAL_GEOMETRY) {
                visitLiteralGeometry(expression);
            } else {
                super.visit(expression);
            }
        } catch (java.io.IOException ioe) {
            LOGGER.warning("Unable to export expression" + ioe);
            throw new RuntimeException("io error while writing", ioe);
        }
    }
    
    /**
     * Writes the SQL for a Compare Filter.
     *
     * DJB: note, postgis overwrites this implementation because of the way
     *       null is handled.  This is for <PropertyIsNull> filters and <PropertyIsEqual> filters
     *       are handled.  They will come here with "property = null".  
     *       NOTE:
     *        SELECT * FROM <table> WHERE <column> isnull;  -- postgresql
     *        SELECT * FROM <table> WHERE isnull(<column>); -- oracle???
     * 
     * @param filter the comparison to be turned into SQL.
     *
     * @throws RuntimeException for io exception with writer
     */
    public void visit(CompareFilter filter) throws RuntimeException {
        LOGGER.finer("exporting SQL ComparisonFilter");

        DefaultExpression left = (DefaultExpression) filter.getLeftValue();
        DefaultExpression right = (DefaultExpression) filter.getRightValue();
        LOGGER.finer("Filter type id is " + filter.getFilterType());
        LOGGER.finer("Filter type text is "
            + comparisions.get(new Integer(filter.getFilterType())));

        String type = (String) comparisions.get(new Integer(
                    filter.getFilterType()));

        try {
        	// a bit hacky, but what else can we really do?
        	if ( (right == null) && (filter.getFilterType()==FilterType.COMPARE_EQUALS ) )
        	{
        		left.accept(this);
        		out.write(" isnull");
        	}
        	else
        	{
        		//check for case insentivity (TODO: perhaps move this up to jdbc)
        		if ( !filter.isMatchingCase() ) {
        			//only for == or != 
        			if ( filter.getFilterType() == Filter.COMPARE_EQUALS || 
        					filter.getFilterType() == Filter.COMPARE_NOT_EQUALS ) {
        				
        				//only for strings
            			if ( left.getType() == Expression.LITERAL_STRING  
            					|| right.getType() == Expression.LITERAL_STRING ) {
            				
            				out.write( "lower(" ); left.accept( this ); out.write( ")");
            				out.write( " " + type + " " );
            				out.write( "lower(" ); right.accept( this ); out.write( ")");
            			
            				return;
            			}
        			}
        		}
        		
    			//normal execution
    			left.accept(this);
        		out.write(" " + type + " ");
        		right.accept(this);
    		}
        } catch (java.io.IOException ioe) {
            throw new RuntimeException(IO_ERROR, ioe);
        }
    }
    
}
