/*
 *  Copyright 2012 Eric F. Savage, code@efsavage.com
 *
 *   Licensed under the Apache License, Version 2.0 (the "License");
 *   you may not use this file except in compliance with the License.
 *   You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *   Unless required by applicable law or agreed to in writing, software
 *   distributed under the License is distributed on an "AS IS" BASIS,
 *   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *   See the License for the specific language governing permissions and
 *   limitations under the License.
 */
package com.ajah.spring.jdbc.criteria;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.logging.Level;

import com.ajah.util.AjahUtils;
import com.ajah.util.CollectionUtils;
import com.ajah.util.StringUtils;
import com.ajah.util.lang.NameValuePair;

import lombok.extern.java.Log;

/**
 * Object-oriented way to construct a WHERE statement.
 * 
 * @author <a href="http://efsavage.com">Eric F. Savage</a>,
 *         <a href="mailto:code@efsavage.com">code@efsavage.com</a>.
 * 
 */
@Log
// TODO add gt/lt/ge/le/ne
// TODO handle Date params
public class Criteria extends AbstractCriteria<Criteria> {

	private List<NameValuePair<String>> gts = null;
	private List<NameValuePair<String>> gtes = null;
	private List<NameValuePair<String>> ltes = null;
	private List<NameValuePair<String>> likes = null;
	private List<NameValuePair<String>> reverseLikes = null;
	private List<NameValuePair<String>> joins = null;
	private List<NameValuePair<Order>> orderBys = null;
	private List<SubCriteria> ands = null;
	private List<SubCriteria> ors = null;
	private long offset = 0;
	private long rowCount = 0;

	/**
	 * A subclause, included as an AND, but may contain ORs.
	 * 
	 * @param subCriteria
	 *            The subCriteria to include
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria and(final SubCriteria subCriteria) {
		AjahUtils.requireParam(subCriteria, "field");
		if (this.ands == null) {
			this.ands = new ArrayList<>();
		}
		this.ands.add(subCriteria);
		return this;
	}

	/**
	 * Add an "ORDER BY" clause for the field with an ascending order.
	 * 
	 * @param field
	 *            The field to sort by.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria asc(final String field) {
		AjahUtils.requireParam(field, "field");
		return orderBy(field, Order.ASC);
	}

	/**
	 * Add an "ORDER BY" clause for the field with an ascending order.
	 * 
	 * @param field
	 *            The field to sort by.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria desc(final String field) {
		AjahUtils.requireParam(field, "field");
		return orderBy(field, Order.DESC);
	}

	/**
	 * Returns the LIMIT number of this Criteria. The default value is 0, which
	 * will yield a query without a LIMIT clause.
	 * 
	 * @return The number of records this criteria should yield, or 0 which
	 *         means unlimited.
	 */
	public Limit getLimit() {
		return new Limit(this.offset, this.rowCount);
	}

	/**
	 * Returns the SQL for the ORDER BY portion of this query, or an empty
	 * string.
	 * 
	 * @return The SQL for the ORDER BY portion of this query, or an empty
	 *         string.
	 */
	public String getOrderBySql() {
		if (CollectionUtils.isEmpty(this.orderBys)) {
			return " ";
		}
		final StringBuilder sql = new StringBuilder();
		boolean first = true;
		for (final NameValuePair<Order> orderBy : this.orderBys) {
			if (first) {
				sql.append(" ORDER BY ");
				first = false;
			} else {
				sql.append(",");
			}
			sql.append(orderBy.getName());
			if (orderBy.getValue() == null) {
				// Do nothing
			} else if (orderBy.getValue() != Order.ASC) {
				sql.append(" ");
				sql.append(orderBy.getValue().name());
			}
		}
		return sql.toString();
	}

	/**
	 * @see com.ajah.spring.jdbc.criteria.AbstractCriteria#getThis()
	 */
	@Override
	protected Criteria getThis() {
		return this;
	}

	public Where getWhere() {
		return getWhere(null);
	}

	/**
	 * Constructs a {@link Where} object from this instance, suitable for
	 * creating a prepared SQL statement.
	 * 
	 * @return A where clause that is equivalent to this criteria.
	 */
	public Where getWhere(final String tableName) {
		final List<String> values = new ArrayList<>();
		final StringBuilder where = new StringBuilder();
		boolean first = true;
		final String tablePrefix = StringUtils.isBlank(tableName) ? "" : "`" + tableName + "`.";

		if (!CollectionUtils.isEmpty(this.eqs)) {
			for (final NameValuePair<String> eq : this.eqs) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}

				where.append(tablePrefix);
				if (eq.getName().contains("`") || eq.getName().contains(".")) {
					where.append(eq.getName());
				} else {
					where.append("`");
					where.append(eq.getName());
					where.append("`");
				}
				if (eq.getValue() == null) {
					where.append(" IS NULL");
				} else {
					where.append("=?");
					values.add(eq.getValue());
				}
			}
		}
		if (!CollectionUtils.isEmpty(this.neqs)) {
			for (final NameValuePair<String> neq : this.neqs) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}

				where.append(tablePrefix);
				where.append("`");
				where.append(neq.getName());
				where.append("`");
				if (neq.getValue() == null) {
					where.append(" IS NOT NULL");
				} else {
					where.append("<>?");
					values.add(neq.getValue());
				}
			}
		}
		if (!CollectionUtils.isEmpty(this.gtes)) {
			for (final NameValuePair<String> gte : this.gtes) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}
				where.append(tablePrefix);
				where.append("`");
				where.append(gte.getName());
				where.append("`");
				where.append(">=?");
				values.add(gte.getValue());
			}
		}
		if (!CollectionUtils.isEmpty(this.gts)) {
			for (final NameValuePair<String> gt : this.gts) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}
				where.append(tablePrefix);
				where.append("`");
				where.append(gt.getName());
				where.append("`");
				where.append(">?");
				values.add(gt.getValue());
			}
		}
		if (!CollectionUtils.isEmpty(this.ltes)) {
			for (final NameValuePair<String> lte : this.ltes) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}
				where.append(tablePrefix);
				where.append("`");
				where.append(lte.getName());
				where.append("`");
				where.append("<=?");
				values.add(lte.getValue());
			}
		}
		if (!CollectionUtils.isEmpty(this.joins)) {
			for (final NameValuePair<String> join : this.joins) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}
				where.append(join.getName());
				where.append("=");
				where.append(join.getValue());
			}
		}
		if (!CollectionUtils.isEmpty(this.likes)) {
			for (final NameValuePair<String> like : this.likes) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}
				where.append(tablePrefix);
				if (like.getName().contains("`") || like.getName().contains(".")) {
					where.append(like.getName());
				} else {
					where.append("`");
					where.append(like.getName());
					where.append("`");
				}
				where.append(" LIKE '");
				where.append(like.getValue().replaceAll("'", "\\'"));
				where.append("'");
			}
		}
		if (!CollectionUtils.isEmpty(this.reverseLikes)) {
			for (final NameValuePair<String> reverseLike : this.reverseLikes) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}
				where.append(tablePrefix);
				where.append("'");
				where.append(reverseLike.getValue().replaceAll("'", "\\'"));
				where.append("'");
				where.append(" LIKE ");
				where.append(reverseLike.getName());
			}
		}
		if (!CollectionUtils.isEmpty(this.ands)) {
			for (final SubCriteria and : this.ands) {
				if (first) {
					first = false;
				} else {
					where.append(" AND ");
				}
				where.append(and.getWhere().getSql(false));
				values.addAll(and.getWhere().getValues());
			}
		}
		if (!CollectionUtils.isEmpty(this.ors)) {
			for (final SubCriteria or : this.ors) {
				if (first) {
					first = false;
				} else {
					where.append(" OR ");
				}
				where.append(or.getWhere().getSql(false));
				values.addAll(or.getWhere().getValues());
			}
		}
		if (log.isLoggable(Level.FINEST)) {
			log.finest(where.toString());
		}
		return new Where(where.toString(), values);
	}

	/**
	 * A greater-than match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria gt(final String field, final long value) {
		AjahUtils.requireParam(field, "field");
		return gt(field, String.valueOf(value));
	}

	/**
	 * A greater-than field match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than .
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria gt(final String field, final String value) {
		AjahUtils.requireParam(field, "field");
		if (this.gts == null) {
			this.gts = new ArrayList<>();
		}
		this.gts.add(new NameValuePair<>(field, value));
		return this;
	}

	/**
	 * A greater-than or equal to match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than or equal to.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria gte(final String field, final long value) {
		AjahUtils.requireParam(field, "field");
		return gte(field, String.valueOf(value));
	}

	/**
	 * A greater-than or equal to match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than or equal to.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria gte(final String field, final BigDecimal value) {
		AjahUtils.requireParam(field, "field");
		return gte(field, value.toString());
	}

	/**
	 * A greater-than or equal to match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than or equal to.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria gte(final String field, final Date value) {
		return gte(field, value.getTime());
	}

	/**
	 * A greater-than or equal-to field match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than or equal to.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria gte(final String field, final String value) {
		AjahUtils.requireParam(field, "field");
		if (this.gtes == null) {
			this.gtes = new ArrayList<>();
		}
		this.gtes.add(new NameValuePair<>(field, value));
		return this;
	}

	/**
	 * A join match.
	 * 
	 * @param table1
	 *            The first table to join.
	 * @param table2
	 *            The second table to join.
	 * @param field
	 *            The field to join on (same field name in both tables).
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria join(final String table1, final String table2, final String field) {
		return join(table1, table2, field, field);
	}

	/**
	 * A join match.
	 * 
	 * @param table1
	 *            The first table to join.
	 * @param table2
	 *            The second table to join.
	 * @param field1
	 *            The field of the first table to join on.
	 * @param field2
	 *            The field of the second table to join on.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria join(final String table1, final String table2, final String field1, final String field2) {
		if (this.joins == null) {
			this.joins = new ArrayList<>();
		}
		this.joins.add(new NameValuePair<>("`" + table1 + "`.`" + field1 + "`", "`" + table2 + "`.`" + field2 + "`"));
		return this;
	}

	/**
	 * A LIKE field match. Uses the query as-is (i.e. add your own wildcards).
	 * 
	 * @param field
	 *            The field to match
	 * @param pattern
	 *            The pattern the field must match.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria like(final String field, final String pattern) {
		AjahUtils.requireParam(field, "field");
		AjahUtils.requireParam(pattern, "pattern");
		if (this.likes == null) {
			this.likes = new ArrayList<>();
		}
		this.likes.add(new NameValuePair<>(field, pattern));
		return this;
	}

	/**
	 * A less-than or equal to match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than or equal to.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria lte(final String field, final long value) {
		AjahUtils.requireParam(field, "field");
		return lte(field, String.valueOf(value));
	}

	/**
	 * A less-than or equal to match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be greater than or equal to.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria lte(final String field, final Date value) {
		AjahUtils.requireParam(field, "field");
		return lte(field, String.valueOf(value.getTime()));
	}

	/**
	 * A less-than or equal-to field match.
	 * 
	 * @param field
	 *            The field to match
	 * @param value
	 *            The value the field must be less than or equal to.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria lte(final String field, final String value) {
		AjahUtils.requireParam(field, "field");
		if (this.ltes == null) {
			this.ltes = new ArrayList<>();
		}
		this.ltes.add(new NameValuePair<>(field, value));
		return this;
	}

	/**
	 * Sets the offset, i.e. the position of the first result.
	 * 
	 * @param offsetIndex
	 *            The offset, i.e. the position of the first result.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria offset(final long offsetIndex) {
		this.offset = offsetIndex;
		return this;
	}

	/**
	 * A subclause, included as an AND, but may contain ORs.
	 * 
	 * @param subCriteria
	 *            The subCriteria to include
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria or(final SubCriteria subCriteria) {
		AjahUtils.requireParam(subCriteria, "subCriteria");
		if (this.ors == null) {
			this.ors = new ArrayList<>();
		}
		this.ors.add(subCriteria);
		return this;
	}

	/**
	 * Add an "ORDER BY" clause.
	 * 
	 * @param field
	 *            The field to sort by.
	 * @param order
	 *            The order by which to sort.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria orderBy(final String field, final Order order) {
		AjahUtils.requireParam(field, "field");
		AjahUtils.requireParam(order, "order");
		if (this.orderBys == null) {
			this.orderBys = new ArrayList<>();
		}
		this.orderBys.add(new NameValuePair<>(field, order));
		return this;
	}

	/**
	 * Adds an ORDER BY RAND() sort.
	 * 
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria randomOrder() {
		if (this.orderBys == null) {
			this.orderBys = new ArrayList<>();
		}
		this.orderBys.add(new NameValuePair<>("RAND()", null));
		return this;
	}

	/**
	 * A reverse LIKE field match. Intended for when the wildcard is in the
	 * database, e.g. 'value' like field. Uses the query as-is (i.e. add your
	 * own wildcards).
	 * 
	 * @param field
	 *            The field to match
	 * @param pattern
	 *            The pattern the field must match.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria reverseLike(final String field, final String pattern) {
		AjahUtils.requireParam(field, "field");
		AjahUtils.requireParam(pattern, "pattern");
		if (this.reverseLikes == null) {
			this.reverseLikes = new ArrayList<>();
		}
		this.reverseLikes.add(new NameValuePair<>(field, pattern));
		return this;
	}

	/**
	 * Sets the maximum number of rows to fetch.
	 * 
	 * @param maximumRowsFetched
	 *            The maximum number of rows to fetch.
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria rows(final long maximumRowsFetched) {
		this.rowCount = maximumRowsFetched;
		return this;
	}

	/**
	 * Sets the number of rows to fetch to 1.
	 * 
	 * @see Criteria#rows(long)
	 * @return Criteria instance the method was invoked on (for chaining).
	 */
	public Criteria unique() {
		this.rowCount = 1;
		return this;
	}

	public Criteria eq(String field, LocalDate from) {
		return eq(field, from.toString());
	}

}
