/*
 * JBoss, Home of Professional Open Source.
 * See the COPYRIGHT.txt file distributed with this work for information
 * regarding copyright ownership.  Some portions may be licensed
 * to Red Hat, Inc. under one or more contributor license agreements.
 *
 * 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; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * 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.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
 * 02110-1301 USA.
 */
package org.teiid.translator.odata;

import static org.teiid.language.SQLConstants.Reserved.*;

import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;

import javax.ws.rs.core.UriBuilder;

import org.teiid.language.*;
import org.teiid.language.SQLConstants.Tokens;
import org.teiid.language.SortSpecification.Ordering;
import org.teiid.language.visitor.HierarchyVisitor;
import org.teiid.metadata.Column;
import org.teiid.metadata.FunctionMethod;
import org.teiid.metadata.RuntimeMetadata;
import org.teiid.metadata.Table;
import org.teiid.translator.SourceSystemFunctions;
import org.teiid.translator.TranslatorException;

public class ODataSQLVisitor extends HierarchyVisitor {
    private static Map<String, String> infixFunctions = new HashMap<String, String>();
    static {
    	infixFunctions.put("%", "mod");//$NON-NLS-1$ //$NON-NLS-2$
    	infixFunctions.put("+", "add");//$NON-NLS-1$ //$NON-NLS-2$
    	infixFunctions.put("-", "sub");//$NON-NLS-1$ //$NON-NLS-2$
    	infixFunctions.put("*", "mul");//$NON-NLS-1$ //$NON-NLS-2$
    	infixFunctions.put("/", "div");//$NON-NLS-1$ //$NON-NLS-2$
    }

	protected ArrayList<TranslatorException> exceptions = new ArrayList<TranslatorException>();
	protected QueryExpression command;
	protected ODataExecutionFactory executionFactory;
	protected RuntimeMetadata metadata;
	protected ArrayList<Column> selectColumns = new ArrayList<Column>();
	protected StringBuilder filter = new StringBuilder();
	private EntitiesInQuery entities = new EntitiesInQuery();
	private Integer skip;
	private Integer top;
	private StringBuilder orderBy = new StringBuilder();
	private boolean count = false;

	public Column[] getSelect(){
		return this.selectColumns.toArray(new Column[this.selectColumns.size()]);
	}

	public boolean isCount() {
		return this.count;
	}

	public boolean isKeyLookup() {
		return this.entities.isKeyLookup();
	}

	public Table getEnityTable() {
		return this.entities.getFinalEntity();
	}

	public String getEnitityURL() {
		StringBuilder url = new StringBuilder();
		this.entities.append(url);
		return url.toString();
	}

	public String buildURL() {
    	StringBuilder url = new StringBuilder();
    	this.entities.append(url);
    	if (this.count) {
    		url.append("/$count"); //$NON-NLS-1$
    	}
    	UriBuilder uriBuilder = UriBuilder.fromPath(url.toString());

    	if (this.filter.length() > 0) {
    		uriBuilder.queryParam("$filter", this.filter.toString()); //$NON-NLS-1$
    	}

    	if (this.orderBy.length() > 0) {
    		uriBuilder.queryParam("$orderby", this.orderBy.toString()); //$NON-NLS-1$
    	}

    	if (!this.selectColumns.isEmpty()) {
    		LinkedHashSet<String> select = new LinkedHashSet<String>();
    		for (Column column:this.selectColumns) {
    			select.add(getColumnName(column));
    		}
    		StringBuilder sb = new StringBuilder();
    		Iterator<String> it = select.iterator();
    		while(it.hasNext()) {
    			sb.append(it.next());
    			if (it.hasNext()) {
    				sb.append(Tokens.COMMA);
    			}
    		}
    		uriBuilder.queryParam("$select", sb.toString()); //$NON-NLS-1$
    	}
    	if (this.skip != null) {
    		uriBuilder.queryParam("$skip", this.skip); //$NON-NLS-1$
    	}
    	if (this.top != null) {
    		uriBuilder.queryParam("$top", this.top); //$NON-NLS-1$
    	}
    	//if (!this.count) {
    	//	uriBuilder.queryParam("$format", "atom"); //$NON-NLS-1$ //$NON-NLS-2$
    	//}

    	URI uri = uriBuilder.build();
        return uri.toString();
	}

	public ODataSQLVisitor(ODataExecutionFactory executionFactory,
			RuntimeMetadata metadata) {
		this.executionFactory = executionFactory;
		this.metadata = metadata;
	}

	@Override
    public void visit(Comparison obj) {		
        Expression left = obj.getLeftExpression();
        
        if(!this.executionFactory.supportsOdataBooleanFunctionsWithComparison() 
        		&& left instanceof Function
        		&& "boolean".equals(((Function)left).getMetadataObject().getOutputParameter().getRuntimeType())) {
        	visitComparisonWithBooleanFunction(obj);
        	// early exit
        	return;
        }
		append(left);
        this.filter.append(Tokens.SPACE);
        switch(obj.getOperator()) {
        case EQ:
        	this.filter.append("eq"); //$NON-NLS-1$
        	break;
        case NE:
        	this.filter.append("ne"); //$NON-NLS-1$
        	break;
        case LT:
        	this.filter.append("lt"); //$NON-NLS-1$
        	break;
        case LE:
        	this.filter.append("le"); //$NON-NLS-1$
        	break;
        case GT:
        	this.filter.append("gt"); //$NON-NLS-1$
        	break;
        case GE:
        	this.filter.append("ge"); //$NON-NLS-1$
        	break;
        }
        this.filter.append(Tokens.SPACE);
        appendRightComparison(obj);
    }
	
	public void visitComparisonWithBooleanFunction(Comparison obj) {
		boolean truthiness = SQLConstants.Reserved.TRUE.equals(obj.getRightExpression().toString());
		boolean isNot = !truthiness;
		switch(obj.getOperator()) {
        case EQ:
        	break;
        case NE:
        	isNot = !isNot;
        	break;
        default:
        	this.exceptions.add(new TranslatorException(
        			ODataPlugin.Util.gs(ODataPlugin.Event.TEIID17018, ((Function)obj.getLeftExpression()).getName())));
        }
		if(isNot) {
			// can't use a Not object, because it requires a Condition inside,
			// and we don't have support for generic unary conditions
			this.filter.append(NOT)
		        .append(Tokens.SPACE)
		        .append(Tokens.LPAREN);
		    append(obj.getLeftExpression());
		    this.filter.append(Tokens.RPAREN);
		} else {
			append(obj.getLeftExpression());
		}
	}

	protected void appendRightComparison(Comparison obj) {
		append(obj.getRightExpression());
	}

	@Override
    public void visit(AndOr obj) {
        String opString = obj.getOperator().name().toLowerCase();
        appendNestedCondition(obj, obj.getLeftCondition());
	    this.filter.append(Tokens.SPACE)
	          .append(opString)
	          .append(Tokens.SPACE);
        appendNestedCondition(obj, obj.getRightCondition());
    }

    protected void appendNestedCondition(AndOr parent, Condition condition) {
    	if (condition instanceof AndOr) {
    		AndOr nested = (AndOr)condition;
    		if (nested.getOperator() != parent.getOperator()) {
    			this.filter.append(Tokens.LPAREN);
    			append(condition);
    			this.filter.append(Tokens.RPAREN);
    			return;
    		}
    	}
    	append(condition);
    }

    @Override
    public void visit(ColumnReference obj) {
		this.filter.append(obj.getMetadataObject().getName());
	}

    protected boolean isInfixFunction(String function) {
    	return infixFunctions.containsKey(function);
    }

	@Override
    public void visit(Function obj) {
    	if (this.executionFactory.getFunctionModifiers().containsKey(obj.getName())) {
            this.executionFactory.getFunctionModifiers().get(obj.getName()).translate(obj);
    	}

        String name = obj.getName();
        List<Expression> args = obj.getParameters();
        if(isInfixFunction(name)) {
            this.filter.append(Tokens.LPAREN);
            if(args != null) {
                for(int i=0; i<args.size(); i++) {
                    append(args.get(i));
                    if(i < (args.size()-1)) {
                        this.filter.append(Tokens.SPACE);
                        this.filter.append(infixFunctions.get(name));
                        this.filter.append(Tokens.SPACE);
                    }
                }
            }
            this.filter.append(Tokens.RPAREN);
        }
        else {
        	FunctionMethod method = obj.getMetadataObject();
        	if (name.startsWith(method.getCategory())) {
        		name = name.substring(method.getCategory().length()+1);
        	}
            this.filter.append(name)
                  .append(Tokens.LPAREN);
            if (args != null && args.size() != 0) {
            	if (SourceSystemFunctions.ENDSWITH.equalsIgnoreCase(name)) {
            		append(args.get(1));
            		this.filter.append(Tokens.COMMA);
            		append(args.get(0));
            	} else {
	                for (int i = 0; i < args.size(); i++) {
	                    append(args.get(i));
	                    if (i < args.size()-1) {
	                    	this.filter.append(Tokens.COMMA);
	                    }
	                }
            	}
            }
            this.filter.append(Tokens.RPAREN);
        }
	}

	@Override
    public void visit(NamedTable obj) {
		this.entities.addEntity(obj.getMetadataObject());
	}

	@Override
    public void visit(IsNull obj) {
        if (obj.isNegated()) {
            this.filter.append(NOT.toLowerCase()).append(Tokens.LPAREN);
        }
    	appendNested(obj.getExpression());
        this.filter.append(Tokens.SPACE);
        this.filter.append("eq").append(Tokens.SPACE); //$NON-NLS-1$
        this.filter.append(NULL.toLowerCase());
        if (obj.isNegated()) {
            this.filter.append(Tokens.RPAREN);
        }
	}

	private void appendNested(Expression ex) {
		boolean useParens = ex instanceof Condition;
    	if (useParens) {
    		this.filter.append(Tokens.LPAREN);
    	}
        append(ex);
        if (useParens) {
        	this.filter.append(Tokens.RPAREN);
        }
	}

	@Override
    public void visit(Join obj) {
		// joins are not used currently
		if (obj.getLeftItem() instanceof NamedTable && obj.getRightItem() instanceof NamedTable) {
			this.entities.addEntity(((NamedTable)obj.getLeftItem()).getMetadataObject());
			this.entities.addEntity(((NamedTable)obj.getRightItem()).getMetadataObject());
			obj.setCondition(buildEntityKey(obj.getCondition()));
			visitNode(obj.getCondition());
		}
		else {
	        visitNode(obj.getLeftItem());
	        visitNode(obj.getRightItem());
	        visitNode(obj.getCondition());
		}
	}

	@Override
    public void visit(Limit obj) {
		if (obj.getRowOffset() != 0) {
			this.skip = new Integer(obj.getRowOffset());
		}
		if (obj.getRowLimit() != 0) {
			this.top = new Integer(obj.getRowLimit());
		}
	}

	@Override
    public void visit(Literal obj) {
    	this.executionFactory.convertToODataInput(obj, this.filter);
	}

	@Override
    public void visit(Not obj) {
        this.filter.append(NOT)
        .append(Tokens.SPACE)
        .append(Tokens.LPAREN);
        append(obj.getCriteria());
        this.filter.append(Tokens.RPAREN);
	}

	@Override
    public void visit(OrderBy obj) {
		 append(obj.getSortSpecifications());
	}

	@Override
    public void visit(SortSpecification obj) {
		if (this.orderBy.length() > 0) {
			this.orderBy.append(Tokens.COMMA);
		}
		ColumnReference column = (ColumnReference)obj.getExpression();
		this.orderBy.append(column.getMetadataObject().getName());
		// default is ascending
        if (obj.getOrdering() == Ordering.DESC) {
        	this.orderBy.append(Tokens.SPACE).append(DESC.toLowerCase());
        }
	}

	@Override
    public void visit(Select obj) {
        visitNodes(obj.getFrom());
        obj.setWhere(buildEntityKey(obj.getWhere()));
        visitNode(obj.getWhere());
        visitNode(obj.getOrderBy());
        visitNode(obj.getLimit());
        visitNodes(obj.getDerivedColumns());
	}

	protected Condition buildEntityKey(Condition obj) {
		List<Condition> crits = LanguageUtil.separateCriteriaByAnd(obj);
        if (!crits.isEmpty()) {
        	boolean modified = false;
        	for(Iterator<Condition> iter = crits.iterator(); iter.hasNext();) {
        		Condition crit = iter.next();
        		if (crit instanceof Comparison) {
        			Comparison left = (Comparison) crit;
        			boolean leftAdded = this.entities.addEnityKey(left);
        			if (leftAdded) {
        				iter.remove();
        				modified = true;
        			}
        		}
        	}
        	if (this.entities.valid() && modified) {
    			return LanguageUtil.combineCriteria(crits);
        	}
        }
        return obj;
	}

	@Override
    public void visit(DerivedColumn obj) {
		if (obj.getExpression() instanceof ColumnReference) {
			Column column = ((ColumnReference)obj.getExpression()).getMetadataObject();
			String joinColumn = column.getProperty(ODataMetadataProcessor.JOIN_COLUMN, false);
			if (joinColumn != null && Boolean.valueOf(joinColumn)) {
				this.exceptions.add(new TranslatorException(ODataPlugin.Util.gs(ODataPlugin.Event.TEIID17006, column.getName())));
			}
			this.selectColumns.add(column);
		}
		else if (obj.getExpression() instanceof AggregateFunction) {
			AggregateFunction func = (AggregateFunction)obj.getExpression();
			if (func.getName().equalsIgnoreCase("COUNT")) { //$NON-NLS-1$
				this.count = true;
			}
			else {
				this.exceptions.add(new TranslatorException(ODataPlugin.Util.gs(ODataPlugin.Event.TEIID17007, func.getName())));
			}
		}
		else {
			this.exceptions.add(new TranslatorException(ODataPlugin.Util.gs(ODataPlugin.Event.TEIID17008)));
		}
	}

	private String getColumnName(Column column) {
		String columnName = column.getName();
		// Check if this is a embedded column, if it is then only
		// add the parent type
		String columnGroup = column.getProperty(ODataMetadataProcessor.COLUMN_GROUP, false);
		if (columnGroup != null) {
			columnName = columnGroup;
		}
		return columnName;
	}

    public void append(LanguageObject obj) {
    	visitNode(obj);
    }

    protected void append(List<? extends LanguageObject> items) {
        if (items != null && items.size() != 0) {
            for (int i = 0; i < items.size(); i++) {
                append(items.get(i));
            }
        }
    }

    protected void append(LanguageObject[] items) {
        if (items != null && items.length != 0) {
            for (int i = 0; i < items.length; i++) {
                append(items[i]);
            }
        }
    }

    static class Entity {
    	Table table;
    	Map<Column, Literal> pkValues = new LinkedHashMap<Column, Literal>();
    	boolean hasValidKey = false;
    	List<Object[]> relations = new ArrayList<Object[]>();

    	public Entity(Table t) {
    		this.table = t;
    	}

    	public void addKeyValue(Column column, Literal value) {
    		addKeyValue(column, value, true);
    	}

    	private void addKeyValue(Column column, Literal value, boolean walkRelations) {
    		// add in self key
    		this.pkValues.put(column, value);

    		if (walkRelations) {
	    		// See any other relations exist.
	    		for (Object[] relation:this.relations) {
	    			if (column.equals(relation[0])){
	    				((Entity)relation[2]).addKeyValue((Column)relation[1], value, false);
	    			}
	    		}
    		}

    		for (Column col:this.table.getPrimaryKey().getColumns()) {
	        	if (this.pkValues.get(col) == null) {
	        		return;
	        	}
	        }
    		this.hasValidKey = true;
    	}

    	public boolean hasValidKey() {
	        return this.hasValidKey;
    	}

		public void addRelation(Column self, Column other, Entity otherEntity) {
			this.relations.add(new Object[] {self, other, otherEntity});
		}
    }

    class EntitiesInQuery {
    	ArrayList<Entity> entities = new ArrayList<ODataSQLVisitor.Entity>();

    	public void append(StringBuilder url) {
    		if (this.entities.size() == 1) {
    			addEntityToURL(url, this.entities.get(0));
    		}
    		else if (this.entities.size() > 1) {
    			for (int i = 0; i < this.entities.size()-1; i++) {
    				addEntityToURL(url, this.entities.get(i));
    				url.append("/"); //$NON-NLS-1$
    			}
    			addEntityToURL(url, this.entities.get(this.entities.size()-1));
    		}
    	}

		public boolean isKeyLookup() {
			return this.entities.get(this.entities.size()-1).hasValidKey();
		}

		public Table getFinalEntity() {
			return this.entities.get(this.entities.size()-1).table;
		}

		private void addEntityToURL(StringBuilder url, Entity entity) {
			url.append(entity.table.getName());
        	if (entity.hasValidKey()) {
        		boolean useNames = entity.pkValues.size() > 1; // multi-key PK, use the name value pairs
        		url.append("("); //$NON-NLS-1$
        		boolean firstKey = true;
        		for (Column c:entity.pkValues.keySet()) {
        			if (firstKey) {
        				firstKey = false;
        			}
        			else {
        				url.append(Tokens.COMMA);
        			}
        			if (useNames) {
        				url.append(c.getName()).append(Tokens.EQ);
        			}
        			ODataSQLVisitor.this.executionFactory.convertToODataInput(entity.pkValues.get(c), url);
        		}
        		url.append(")"); //$NON-NLS-1$
        	}
		}

		public void addEntity(Table table) {
			Entity entity = new Entity(table);
			this.entities.add(entity);
		}

		private Entity getEntity(Table table) {
			for (Entity e:this.entities) {
				if (e.table.equals(table)) {
					return e;
				}
			}
			return null;
		}

		private boolean addEnityKey(Comparison obj) {
			if (obj.getOperator().equals(Comparison.Operator.EQ)) {
				if (obj.getLeftExpression() instanceof ColumnReference && obj.getRightExpression() instanceof Literal) {
					ColumnReference columnRef = (ColumnReference)obj.getLeftExpression();
					Table parentTable = columnRef.getTable().getMetadataObject();
					Entity entity = getEntity(parentTable);
					if (entity != null) {
						Column column = columnRef.getMetadataObject();
						if (parentTable.getPrimaryKey().getColumnByName(column.getName())!=null) {
							entity.addKeyValue(column, (Literal)obj.getRightExpression());
							return true;
						}
					}
				}
				if (obj.getLeftExpression() instanceof ColumnReference && obj.getRightExpression() instanceof ColumnReference) {
					Column left = ((ColumnReference)obj.getLeftExpression()).getMetadataObject();
					Column right = ((ColumnReference)obj.getRightExpression()).getMetadataObject();

					if (isJoinOrPkColumn(left)&& isJoinOrPkColumn(right)) {
						// in odata the navigation from parent to child implicit by their keys
						Entity leftEntity = getEntity((Table)left.getParent());
						Entity rightEntity = getEntity((Table)right.getParent());
						leftEntity.addRelation(left, right, rightEntity);
						rightEntity.addRelation(right,left, leftEntity);
						return true;
					}
				}
			}
			return false;
		}

		private boolean isJoinOrPkColumn(Column column) {
			boolean joinColumn = Boolean.valueOf(column.getProperty(ODataMetadataProcessor.JOIN_COLUMN, false));
			if (!joinColumn) {
				Table table = (Table)column.getParent();
				return (table.getPrimaryKey().getColumnByName(column.getName()) != null);

			}
			return false;
		}

		private boolean valid() {
			for (Entity e:this.entities) {
				if (e.hasValidKey()) {
					return true;
				}
			}
			return false;
		}
    }
}
