package eu.dnetlib.functionality.cql.mongo;

import java.io.IOException;
import java.util.List;

import com.google.common.collect.Lists;
import com.mongodb.BasicDBObject;
import com.mongodb.BasicDBObjectBuilder;
import eu.dnetlib.functionality.cql.parse.Relation;
import eu.dnetlib.functionality.cql.parse.Relations;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.bson.conversions.Bson;
import org.bson.types.ObjectId;
import org.joda.time.DateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.ISODateTimeFormat;
import org.z3950.zing.cql.*;

/**
 * Created by claudio on 12/09/16.
 */
public class MongoCqlTranslator {

	private static final Log log = LogFactory.getLog(MongoCqlTranslator.class); // NOPMD by marko on 11/24/08 5:02 PM

	//field names containing dates for the OAI publisher. See also OAICOnfigurationReader constants that are not visible here.
	//TODO: Generalize
	private final List<String> dateFields = Lists.newArrayList("datestamp", "lastCollectionDate");
	/**
	 * Parses the given query string into a mongo DBObject.
	 *
	 * @param query
	 *            String to parse
	 * @return DBObject corresponding to the query string
	 */
	public static Bson toMongo(final String query) throws CQLParseException, IOException {
		log.debug("PARSING: " + query);
		if (StringUtils.isBlank(query)) return new BasicDBObject();
		final Bson parsed = new MongoCqlTranslator().doParse(query);
		log.debug(parsed);
		return parsed;
	}

	private Bson doParse(final String query) throws IOException, CQLParseException {
		CQLParser parser = new CQLParser();
		CQLNode root;
		root = parser.parse(query);
		return doParse(root);
	}

	private Bson doParse(final CQLNode node) throws CQLParseException {
		if (node instanceof CQLTermNode) return doTranslate((CQLTermNode) node);
		if (node instanceof CQLBooleanNode) return doTranslate((CQLBooleanNode) node);

		throw new RuntimeException("error choice for CQLNode " + node.getClass());
	}

	private Bson doTranslate(final CQLTermNode termNode) throws CQLParseException {
		if (termNode.getTerm().equals("*")  && (termNode.getIndex().equals("*") || termNode.getIndex().equals("cql.serverChoice"))) return new BasicDBObject();
		String relation = termNode.getRelation().getBase();
		Relation rel = Relations.get(relation);
		return this.handleRelationNode(rel, termNode);
	}

	private Bson handleRelationNode(final Relation rel, final CQLTermNode termNode) throws CQLParseException {
		BasicDBObject mongoQueryObject = new BasicDBObject();
		String term = termNode.getTerm();
		String indexName = termNode.getIndex();
		Object termObj = term;
		if (dateFields.contains(indexName)){
			OAIDate termDate = this.parseDate(term);
			return handleDateRelationNode(indexName, rel, termDate);
		}
		if (indexName.equals("_id")) {
			termObj = new ObjectId(term);
		}
		switch (rel) {
		case EQUAL:
		case EXACT:
			if(!term.equals("*")) {
				mongoQueryObject.put(indexName, termObj);
			}
			else{
				//special case to handle exist queries such as fieldname = * --> qty: { $exists: true}
				mongoQueryObject.put(indexName, new BasicDBObject("$exists", true));
			}
			break;
		case NOT:
			mongoQueryObject.put(indexName, new BasicDBObject("$ne", termObj));
			break;
		case GT:
			mongoQueryObject.put(indexName, new BasicDBObject("$gt", termObj));
			break;
		case GTE:
			mongoQueryObject.put(indexName, new BasicDBObject("$gte", termObj));
			break;
		case LT:
			mongoQueryObject.put(indexName, new BasicDBObject("$lt", termObj));
			break;
		case LTE:
			mongoQueryObject.put(indexName, new BasicDBObject("$lte", termObj));
			break;
		default:
			throw new CQLParseException("Can't parse query: relation " + rel + " not supported!");
		}
		return mongoQueryObject;
	}

	private Bson doTranslate(final CQLBooleanNode node) throws CQLParseException {
		if (node instanceof CQLAndNode) return getBooleanQuery("$and", node);
		if (node instanceof CQLOrNode) return getBooleanQuery("$or", node);
		if (node instanceof CQLNotNode) return getNotQuery((CQLNotNode) node);
		throw new RuntimeException("error choice for CQLBooleanNode " + node.getClass());
	}

	private Bson getBooleanQuery(final String mongoOperator, final CQLBooleanNode node) throws CQLParseException {
		Bson left = doParse(node.left);
		Bson right = doParse(node.right);
		BasicDBObject opQuery = new BasicDBObject();
		List<Bson> termList = Lists.newArrayList(left, right);
		opQuery.put(mongoOperator, termList);
		return opQuery;
	}

	private Bson getNotQuery(final CQLNotNode node) throws CQLParseException {
		Bson left = doParse(node.left);
		Bson right = doParse(node.right);
		Bson notRight = new BasicDBObject("$not", right);
		BasicDBObject andQuery = new BasicDBObject();
		List<Bson> termList = Lists.newArrayList(left, notRight);
		andQuery.put("$and", termList);
		return andQuery;
	}

	/**
	 * The construction of the query changes based on the granularity of the date to handle.
	 * <p>
	 * If the date has yyyy-MM-ddThh:mm:ssZ granularity we have to create a range query because in mongo we have milliseconds, hence an
	 * exact match will return nothing.
	 * </p>
	 * <p>
	 * If the date has yyyy-MM-dd granularity then we have to trick the query. If we are interested in the date 2013-10-28, the date has
	 * been converted into 2013-10-28T00:00:00Z : if we ask for datestamp = 2013-10-28T00:00:00Z, we'll get nothing: we have to ask for
	 * records whose day is the one specified by the date.
	 *
	 * </p>
	 *
	 * @param indexName
	 * @param rel
	 * @param date
	 * @return
	 */
	private Bson handleDateRelationNode(final String indexName, final Relation rel, final OAIDate date) {
		BasicDBObject mongoQueryObject = new BasicDBObject();
		DateTime fromDate = date.date;
		switch (rel) {
		case EQUAL:
		case EXACT:
			if (date.onlyDate) {
				DateTime endDate = date.date.plusDays(1);
				mongoQueryObject.put(indexName, BasicDBObjectBuilder.start("$gte", fromDate.toDate()).append("$lt", endDate.toDate()).get());
			} else {
				DateTime endDate = date.date.plusSeconds(1);
				mongoQueryObject.put(indexName, BasicDBObjectBuilder.start("$gte", fromDate.toDate()).append("$lt", endDate.toDate()).get());
			}
			break;
		case NOT:
			mongoQueryObject.put(indexName, new BasicDBObject("$ne", fromDate.toDate()));
			break;
		case GT:
			mongoQueryObject.put(indexName, new BasicDBObject("$gt", fromDate.toDate()));
			break;
		case GTE:
			mongoQueryObject.put(indexName, new BasicDBObject("$gte", fromDate.toDate()));
			break;
		case LT:
			mongoQueryObject.put(indexName, new BasicDBObject("$lt", fromDate.toDate()));
			break;
		case LTE:
			/*
			If the request is date <= YYYY-MM-DD then we need to change the date.
			The parseDate returned YYYY-MM-DDT00:00:00Z, but we need YYYY-MM-DDT23:59:59Z.
			To simplify we can add one day and perform < instead of <=.
			 */
			if (date.onlyDate) {
				fromDate = date.date.plusDays(1);
				mongoQueryObject.put(indexName, new BasicDBObject("$lt", fromDate.toDate()));
			} else {
				mongoQueryObject.put(indexName, new BasicDBObject("$lte", fromDate.toDate()));
			}
			break;
		default:
			throw new RuntimeException("Can't parse query: relation " + rel + " not supported!");
		}
		return mongoQueryObject;
	}


	private OAIDate parseDate(final String date) {
		DateTimeFormatter dateNoTimeFormatter = DateTimeFormat.forPattern("yyyy-MM-dd").withZoneUTC();
		DateTimeFormatter iso8601NoMsTimeFormatter = DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ssZ").withZoneUTC();
		DateTimeFormatter iso8601Formatter = ISODateTimeFormat.dateTime().withZoneUTC();
		OAIDate res = null;
		try {
			log.debug("Using default " + iso8601Formatter.getClass());
			DateTime dt = iso8601Formatter.parseDateTime(date);
			res = new OAIDate(dt, false);
		} catch (Exception e) {
			try {
				log.debug("Switching to ISO with no millisecond date formatter: yyyy-MM-dd'T'HH:mm:ssZ");
				DateTime dt = iso8601NoMsTimeFormatter.parseDateTime(date);
				res = new OAIDate(dt, false);
			} catch (Exception ex) {
				log.debug("Switching to simple date formatter: yyyy-MM-dd");
				DateTime dt = dateNoTimeFormatter.parseDateTime(date);
				res = new OAIDate(dt, true);
			}
		}
		return res;
	}

	class OAIDate {

		DateTime date;
		boolean onlyDate;

		OAIDate(final DateTime date, final boolean onlyDate) {
			this.date = date;
			this.onlyDate = onlyDate;
		}

	}


}
