package org.hl7.fhir.instance.model;

/*
 * #%L
 * HAPI FHIR - Core Library
 * %%
 * Copyright (C) 2014 University Health Network
 * %%
 * 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.
 * #L%
 */

import static org.hl7.fhir.instance.model.TemporalPrecisionEnum.*;

import java.text.ParseException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.TimeZone;
import java.util.regex.Pattern;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.time.DateUtils;
import org.apache.commons.lang3.time.FastDateFormat;

public abstract class BaseDateTimeType extends PrimitiveType<Date> {

	private static final long serialVersionUID = 1L;

	/*
	 * Add any new formatters to the static block below!!
	 */
	private static final List<FastDateFormat> ourFormatters;

	private static final Pattern ourYearDashMonthDashDayPattern = Pattern.compile("[0-9]{4}-[0-9]{2}-[0-9]{2}");
	private static final Pattern ourYearDashMonthPattern = Pattern.compile("[0-9]{4}-[0-9]{2}");
	private static final FastDateFormat ourYearFormat = FastDateFormat.getInstance("yyyy");
	private static final FastDateFormat ourYearMonthDayFormat = FastDateFormat.getInstance("yyyy-MM-dd");
	private static final FastDateFormat ourYearMonthDayNoDashesFormat = FastDateFormat.getInstance("yyyyMMdd");
	private static final Pattern ourYearMonthDayPattern = Pattern.compile("[0-9]{4}[0-9]{2}[0-9]{2}");
	private static final FastDateFormat ourYearMonthDayTimeFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss");
	private static final FastDateFormat ourYearMonthDayTimeMilliFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS");
	private static final FastDateFormat ourYearMonthDayTimeMilliUTCZFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", TimeZone.getTimeZone("UTC"));
	private static final FastDateFormat ourYearMonthDayTimeMilliZoneFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss.SSSZZ");
	private static final FastDateFormat ourYearMonthDayTimeUTCZFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("UTC"));
	private static final FastDateFormat ourYearMonthDayTimeZoneFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ssZZ");
	private static final FastDateFormat ourYearMonthFormat = FastDateFormat.getInstance("yyyy-MM");
	private static final FastDateFormat ourYearMonthNoDashesFormat = FastDateFormat.getInstance("yyyyMM");
	private static final Pattern ourYearMonthPattern = Pattern.compile("[0-9]{4}[0-9]{2}");
	private static final Pattern ourYearPattern = Pattern.compile("[0-9]{4}");
	private static final FastDateFormat ourYearMonthDayTimeMinsFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm");
	private static final FastDateFormat ourYearMonthDayTimeMinsUTCZFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC"));
	private static final FastDateFormat ourYearMonthDayTimeMinsZoneFormat = FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mmZZ");

	private static final FastDateFormat ourHumanDateTimeFormat = FastDateFormat.getDateTimeInstance(FastDateFormat.MEDIUM, FastDateFormat.MEDIUM);
	private static final FastDateFormat ourHumanDateFormat = FastDateFormat.getDateInstance(FastDateFormat.MEDIUM);

	static {
		ArrayList<FastDateFormat> formatters = new ArrayList<FastDateFormat>();
		formatters.add(ourYearFormat);
		formatters.add(ourYearMonthDayFormat);
		formatters.add(ourYearMonthDayNoDashesFormat);
		formatters.add(ourYearMonthDayTimeFormat);
		formatters.add(ourYearMonthDayTimeUTCZFormat);
		formatters.add(ourYearMonthDayTimeZoneFormat);
		formatters.add(ourYearMonthDayTimeMilliFormat);
		formatters.add(ourYearMonthDayTimeMilliUTCZFormat);
		formatters.add(ourYearMonthDayTimeMilliZoneFormat);
		formatters.add(ourYearMonthDayTimeMinsFormat);
		formatters.add(ourYearMonthDayTimeMinsUTCZFormat);
		formatters.add(ourYearMonthDayTimeMinsZoneFormat);
		formatters.add(ourYearMonthFormat);
		formatters.add(ourYearMonthNoDashesFormat);
		ourFormatters = Collections.unmodifiableList(formatters);
	}

	private TemporalPrecisionEnum myPrecision = TemporalPrecisionEnum.SECOND;

	private TimeZone myTimeZone;
	private boolean myTimeZoneZulu = false;

	/**
	 * Constructor
	 */
	public BaseDateTimeType() {
		// nothing
	}

	/**
	 * Constructor
	 * 
	 * @throws IllegalArgumentException
	 *             If the specified precision is not allowed for this type
	 */
	public BaseDateTimeType(Date theDate, TemporalPrecisionEnum thePrecision) {
		setValue(theDate, thePrecision);
		if (isPrecisionAllowed(thePrecision) == false) {
			throw new IllegalArgumentException("Invalid date/time string (datatype " + getClass().getSimpleName() + " does not support " + thePrecision + " precision): " + theDate);
		}
	}

	/**
	 * Constructor
	 * 
	 * @throws IllegalArgumentException
	 *             If the specified precision is not allowed for this type
	 */
	public BaseDateTimeType(String theString) {
		setValueAsString(theString);
		if (isPrecisionAllowed(getPrecision()) == false) {
			throw new IllegalArgumentException("Invalid date/time string (datatype " + getClass().getSimpleName() + " does not support " + getPrecision() + " precision): " + theString);
		}
	}

	/**
	 * Constructor
	 */
	public BaseDateTimeType(Date theDate, TemporalPrecisionEnum thePrecision, TimeZone theTimeZone) {
		this(theDate, thePrecision);
		setTimeZone(theTimeZone);
	}

	private void clearTimeZone() {
		myTimeZone = null;
		myTimeZoneZulu = false;
	}

	@Override
	protected String encode(Date theValue) {
		if (theValue == null) {
			return null;
		} else {
			switch (myPrecision) {
			case DAY:
				return ourYearMonthDayFormat.format(theValue);
			case MONTH:
				return ourYearMonthFormat.format(theValue);
			case YEAR:
				return ourYearFormat.format(theValue);
			case MINUTE:
				if (myTimeZoneZulu) {
					GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
					cal.setTime(theValue);
					return ourYearMonthDayTimeMinsFormat.format(cal) + "Z";
				} else if (myTimeZone != null) {
					GregorianCalendar cal = new GregorianCalendar(myTimeZone);
					cal.setTime(theValue);
					return (ourYearMonthDayTimeMinsZoneFormat.format(cal));
				} else {
					return ourYearMonthDayTimeMinsFormat.format(theValue);
				}
			case SECOND:
				if (myTimeZoneZulu) {
					GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
					cal.setTime(theValue);
					return ourYearMonthDayTimeFormat.format(cal) + "Z";
				} else if (myTimeZone != null) {
					GregorianCalendar cal = new GregorianCalendar(myTimeZone);
					cal.setTime(theValue);
					return (ourYearMonthDayTimeZoneFormat.format(cal));
				} else {
					return ourYearMonthDayTimeFormat.format(theValue);
				}
			case MILLI:
				if (myTimeZoneZulu) {
					GregorianCalendar cal = new GregorianCalendar(TimeZone.getTimeZone("GMT"));
					cal.setTime(theValue);
					return ourYearMonthDayTimeMilliFormat.format(cal) + "Z";
				} else if (myTimeZone != null) {
					GregorianCalendar cal = new GregorianCalendar(myTimeZone);
					cal.setTime(theValue);
					return (ourYearMonthDayTimeMilliZoneFormat.format(cal));
				} else {
					return ourYearMonthDayTimeMilliFormat.format(theValue);
				}
			}
			throw new IllegalStateException("Invalid precision (this is a HAPI bug, shouldn't happen): " + myPrecision);
		}
	}

	/**
	 * Returns the default precision for the given datatype
	 */
	protected abstract TemporalPrecisionEnum getDefaultPrecisionForDatatype();

	/**
	 * Gets the precision for this datatype (using the default for the given type if not set)
	 * 
	 * @see #setPrecision(TemporalPrecisionEnum)
	 */
	public TemporalPrecisionEnum getPrecision() {
		if (myPrecision == null) {
			return getDefaultPrecisionForDatatype();
		}
		return myPrecision;
	}

	/**
	 * Returns the TimeZone associated with this dateTime's value. May return <code>null</code> if no timezone was
	 * supplied.
	 */
	public TimeZone getTimeZone() {
		return myTimeZone;
	}

	private boolean hasOffset(String theValue) {
		boolean inTime = false;
		for (int i = 0; i < theValue.length(); i++) {
			switch (theValue.charAt(i)) {
			case 'T':
				inTime = true;
				break;
			case '+':
			case '-':
				if (inTime) {
					return true;
				}
				break;
			}
		}
		return false;
	}

	/**
	 * To be implemented by subclasses to indicate whether the given precision is allowed by this type
	 */
	abstract boolean isPrecisionAllowed(TemporalPrecisionEnum thePrecision);

	public boolean isTimeZoneZulu() {
		return myTimeZoneZulu;
	}

	/**
	 * Returns <code>true</code> if this object represents a date that is today's date
	 * 
	 * @throws NullPointerException
	 *             if {@link #getValue()} returns <code>null</code>
	 */
	public boolean isToday() {
		Validate.notNull(getValue(), getClass().getSimpleName() + " contains null value");
		return DateUtils.isSameDay(new Date(), getValue());
	}

	@Override
	protected Date parse(String theValue) throws IllegalArgumentException {
		try {
			if (theValue.length() == 4 && ourYearPattern.matcher(theValue).matches()) {
				if (!isPrecisionAllowed(YEAR)) {
					// ourLog.debug("Invalid date/time string (datatype " + getClass().getSimpleName() +
					// " does not support YEAR precision): " + theValue);
				}
				setPrecision(YEAR);
				clearTimeZone();
				return ((ourYearFormat).parse(theValue));
			} else if (theValue.length() == 6 && ourYearMonthPattern.matcher(theValue).matches()) {
				// Eg. 198401 (allow this just to be lenient)
				if (!isPrecisionAllowed(MONTH)) {
					// ourLog.debug("Invalid date/time string (datatype " + getClass().getSimpleName() +
					// " does not support DAY precision): " + theValue);
				}
				setPrecision(MONTH);
				clearTimeZone();
				return ((ourYearMonthNoDashesFormat).parse(theValue));
			} else if (theValue.length() == 7 && ourYearDashMonthPattern.matcher(theValue).matches()) {
				// E.g. 1984-01 (this is valid according to the spec)
				if (!isPrecisionAllowed(MONTH)) {
					// ourLog.debug("Invalid date/time string (datatype " + getClass().getSimpleName() +
					// " does not support MONTH precision): " + theValue);
				}
				setPrecision(MONTH);
				clearTimeZone();
				return ((ourYearMonthFormat).parse(theValue));
			} else if (theValue.length() == 8 && ourYearMonthDayPattern.matcher(theValue).matches()) {
				// Eg. 19840101 (allow this just to be lenient)
				if (!isPrecisionAllowed(DAY)) {
					// ourLog.debug("Invalid date/time string (datatype " + getClass().getSimpleName() +
					// " does not support DAY precision): " + theValue);
				}
				setPrecision(DAY);
				clearTimeZone();
				return ((ourYearMonthDayNoDashesFormat).parse(theValue));
			} else if (theValue.length() == 10 && ourYearDashMonthDashDayPattern.matcher(theValue).matches()) {
				// E.g. 1984-01-01 (this is valid according to the spec)
				if (!isPrecisionAllowed(DAY)) {
					// ourLog.debug("Invalid date/time string (datatype " + getClass().getSimpleName() +
					// " does not support DAY precision): " + theValue);
				}
				setPrecision(DAY);
				clearTimeZone();
				return ((ourYearMonthDayFormat).parse(theValue));
			} else if (theValue.length() >= 16) { // date and time with possible time zone
				int firstColonIndex = theValue.indexOf(':');
				if (firstColonIndex == -1) {
					throw new IllegalArgumentException("Invalid date/time string: " + theValue);
				}
				
				boolean hasSeconds = theValue.length() > firstColonIndex+3 ? theValue.charAt(firstColonIndex+3) == ':' : false; 
				
				int dotIndex = theValue.length() >= 18 ? theValue.indexOf('.', 18): -1;
				boolean hasMillis = dotIndex > -1;

//				if (!hasMillis && !isPrecisionAllowed(SECOND)) {
					// ourLog.debug("Invalid date/time string (data type does not support SECONDS precision): " +
					// theValue);
//				} else if (hasMillis && !isPrecisionAllowed(MILLI)) {
					// ourLog.debug("Invalid date/time string (data type " + getClass().getSimpleName() +
					// " does not support MILLIS precision):" + theValue);
//				}

				Date retVal;
				if (hasMillis) {
					try {
						if (hasOffset(theValue)) {
							retVal = ourYearMonthDayTimeMilliZoneFormat.parse(theValue);
						} else if (theValue.endsWith("Z")) {
							retVal = ourYearMonthDayTimeMilliUTCZFormat.parse(theValue);
						} else {
							retVal = ourYearMonthDayTimeMilliFormat.parse(theValue);
						}
					} catch (ParseException p2) {
						throw new IllegalArgumentException("Invalid data/time string (" + p2.getMessage() + "): " + theValue);
					}
					setTimeZone(theValue, hasMillis);
					setPrecision(TemporalPrecisionEnum.MILLI);
				} else if (hasSeconds) {
					try {
						if (hasOffset(theValue)) {
							retVal = ourYearMonthDayTimeZoneFormat.parse(theValue);
						} else if (theValue.endsWith("Z")) {
							retVal = ourYearMonthDayTimeUTCZFormat.parse(theValue);
						} else {
							retVal = ourYearMonthDayTimeFormat.parse(theValue);
						}
					} catch (ParseException p2) {
						throw new IllegalArgumentException("Invalid data/time string (" + p2.getMessage() + "): " + theValue);
					}

					setTimeZone(theValue, hasMillis);
					setPrecision(TemporalPrecisionEnum.SECOND);
				} else {
					try {
						if (hasOffset(theValue)) {
							retVal = ourYearMonthDayTimeMinsZoneFormat.parse(theValue);
						} else if (theValue.endsWith("Z")) {
							retVal = ourYearMonthDayTimeMinsUTCZFormat.parse(theValue);
						} else {
							retVal = ourYearMonthDayTimeMinsFormat.parse(theValue);
						}
					} catch (ParseException p2) {
						throw new IllegalArgumentException("Invalid data/time string (" + p2.getMessage() + "): " + theValue, p2);
					}

					setTimeZone(theValue, hasMillis);
					setPrecision(TemporalPrecisionEnum.MINUTE);
				}

				return retVal;
			} else {
				throw new IllegalArgumentException("Invalid date/time string (invalid length): " + theValue);
			}
		} catch (ParseException e) {
			throw new IllegalArgumentException("Invalid date string (" + e.getMessage() + "): " + theValue);
		}
	}

	/**
	 * Sets the precision for this datatype using field values from {@link Calendar}. Valid values are:
	 * <ul>
	 * <li>{@link Calendar#SECOND}
	 * <li>{@link Calendar#DAY_OF_MONTH}
	 * <li>{@link Calendar#MONTH}
	 * <li>{@link Calendar#YEAR}
	 * </ul>
	 * 
	 * @throws IllegalArgumentException
	 */
	public void setPrecision(TemporalPrecisionEnum thePrecision) throws IllegalArgumentException {
		if (thePrecision == null) {
			throw new NullPointerException("Precision may not be null");
		}
		myPrecision = thePrecision;
		updateStringValue();
	}

	private void setTimeZone(String theValueString, boolean hasMillis) {
		clearTimeZone();
		int timeZoneStart = 19;
		if (hasMillis)
			timeZoneStart += 4;
		if (theValueString.endsWith("Z")) {
			setTimeZoneZulu(true);
		} else if (theValueString.indexOf("GMT", timeZoneStart) != -1) {
			setTimeZone(TimeZone.getTimeZone(theValueString.substring(timeZoneStart)));
		} else if (theValueString.indexOf('+', timeZoneStart) != -1 || theValueString.indexOf('-', timeZoneStart) != -1) {
			setTimeZone(TimeZone.getTimeZone("GMT" + theValueString.substring(timeZoneStart)));
		}
	}

	public void setTimeZone(TimeZone theTimeZone) {
		myTimeZone = theTimeZone;
		updateStringValue();
	}

	public void setTimeZoneZulu(boolean theTimeZoneZulu) {
		myTimeZoneZulu = theTimeZoneZulu;
		updateStringValue();
	}

	@Override
	public BaseDateTimeType setValue(Date theValue) {
		clearTimeZone();
		return (BaseDateTimeType) super.setValue(theValue);
	}

	/**
	 * Sets the value of this date/time using the specified level of precision
	 * 
	 * @param theValue
	 *            The date value
	 * @param thePrecision
	 *            The precision
	 * @throws IllegalArgumentException
	 */
	public void setValue(Date theValue, TemporalPrecisionEnum thePrecision) throws IllegalArgumentException {
		clearTimeZone();
		super.setValue(theValue);
		myPrecision = thePrecision;
	}

	@Override
	public void setValueAsString(String theValue) throws IllegalArgumentException {
		clearTimeZone();
		super.setValueAsString(theValue);
	}

	/**
	 * For unit tests only
	 */
	static List<FastDateFormat> getFormatters() {
		return ourFormatters;
	}

	public boolean before(DateTimeType theDateTimeType) {
		return getValue().before(theDateTimeType.getValue());
	}

	public boolean after(DateTimeType theDateTimeType) {
		return getValue().after(theDateTimeType.getValue());
	}

	/**
	 * Returns a human readable version of this date/time using the system local format
	 */
	public String toHumanDisplay() {
		switch (getPrecision()) {
		case YEAR:
		case MONTH:
		case DAY:
			return ourHumanDateFormat.format(getValue());
		case MILLI:
		case SECOND:
		default:
			return ourHumanDateTimeFormat.format(getValue());
		}
	}

	/**
	 * Returns a view of this date/time as a Calendar object
	 */
	public Calendar toCalendar() {
		Calendar retVal = Calendar.getInstance();
		retVal.setTime(getValue());
		retVal.setTimeZone(getTimeZone());
		return retVal;
	}

	/**
	 * Sets the TimeZone offset in minutes relative to GMT
	 */
	public void setOffsetMinutes(int theZoneOffsetMinutes) {
		int offsetAbs = Math.abs(theZoneOffsetMinutes);

		int mins = offsetAbs % 60;
		int hours = offsetAbs / 60;

		if (theZoneOffsetMinutes < 0) {
			setTimeZone(TimeZone.getTimeZone("GMT-" + hours + ":" + mins));
		} else {
			setTimeZone(TimeZone.getTimeZone("GMT+" + hours + ":" + mins));
		}
	}

	/**
	 * Returns the time in millis as represented by this Date/Time
	 */
	public long getTime() {
		return getValue().getTime();
	}

	/**
	 * Adds the given amount to the field specified by theField
	 * 
	 * @param theField
	 *            The field, uses constants from {@link Calendar} such as {@link Calendar#YEAR}
	 * @param theValue
	 *            The number to add (or subtract for a negative number)
	 */
	public void add(int theField, int theValue) {
		switch (theField) {
		case Calendar.YEAR:
			setValue(DateUtils.addYears(getValue(), theValue), getPrecision());
			break;
		case Calendar.MONTH:
			setValue(DateUtils.addMonths(getValue(), theValue), getPrecision());
			break;
		case Calendar.DATE:
			setValue(DateUtils.addDays(getValue(), theValue), getPrecision());
			break;
		case Calendar.HOUR:
			setValue(DateUtils.addHours(getValue(), theValue), getPrecision());
			break;
		case Calendar.MINUTE:
			setValue(DateUtils.addMinutes(getValue(), theValue), getPrecision());
			break;
		case Calendar.SECOND:
			setValue(DateUtils.addSeconds(getValue(), theValue), getPrecision());
			break;
		case Calendar.MILLISECOND:
			setValue(DateUtils.addMilliseconds(getValue(), theValue), getPrecision());
			break;
		default:
			throw new IllegalArgumentException("Unknown field constant: " + theField);
		}
	}

	protected void setValueAsV3String(String theV3String) {
		if (StringUtils.isBlank(theV3String)) {
			setValue(null);
		} else {
			StringBuilder b = new StringBuilder();
			String timeZone = null;
			for (int i = 0; i < theV3String.length(); i++) {
				char nextChar = theV3String.charAt(i);
				if (nextChar == '+' || nextChar == '-' || nextChar == 'Z') {
					timeZone = (theV3String.substring(i));
					break;
				}
				
				// assertEquals("2013-02-02T20:13:03-05:00", DateAndTime.parseV3("20130202201303-0500").toString());
				if (i == 4 || i == 6) {
					b.append('-');
				} else if (i == 8) {
					b.append('T');
				} else if (i == 10 || i == 12) {
					b.append(':');
				}
				
				b.append(nextChar);
			}

			if (timeZone != null && b.length() > 10) {
				if (timeZone.length() ==5) {
					b.append(timeZone.substring(0, 3));
					b.append(':');
					b.append(timeZone.substring(3));
				}else {
					b.append(timeZone);
				}
			}
			
			setValueAsString(b.toString());
		}
	}

}
