/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.log4j.pattern;

import java.text.DateFormat;
import java.text.FieldPosition;
import java.text.NumberFormat;
import java.text.ParsePosition;
import java.util.Date;
import java.util.TimeZone;

/**
 * CachedDateFormat optimizes the performance of a wrapped DateFormat. The
 * implementation is not thread-safe. If the millisecond pattern is not
 * recognized, the class will only use the cache if the same value is requested.
 *
 */
public final class CachedDateFormat extends DateFormat {
    /**
     * Serialization version.
     */
    private static final long serialVersionUID = 1;
    /**
     * Constant used to represent that there was no change observed when changing
     * the millisecond count.
     */
    public static final int NO_MILLISECONDS = -2;

    /**
     * Supported digit set. If the wrapped DateFormat uses a different unit set, the
     * millisecond pattern will not be recognized and duplicate requests will use
     * the cache.
     */
    private static final String DIGITS = "0123456789";

    /**
     * Constant used to represent that there was an observed change, but was an
     * expected change.
     */
    public static final int UNRECOGNIZED_MILLISECONDS = -1;

    /**
     * First magic number used to detect the millisecond position.
     */
    private static final int MAGIC1 = 654;

    /**
     * Expected representation of first magic number.
     */
    private static final String MAGICSTRING1 = "654";

    /**
     * Second magic number used to detect the millisecond position.
     */
    private static final int MAGIC2 = 987;

    /**
     * Expected representation of second magic number.
     */
    private static final String MAGICSTRING2 = "987";

    /**
     * Expected representation of 0 milliseconds.
     */
    private static final String ZERO_STRING = "000";

    /**
     * Wrapped formatter.
     */
    private final DateFormat formatter;

    /**
     * Index of initial digit of millisecond pattern or UNRECOGNIZED_MILLISECONDS or
     * NO_MILLISECONDS.
     */
    private int millisecondStart;

    /**
     * Integral second preceding the previous convered Date.
     */
    private long slotBegin;

    /**
     * Cache of previous conversion.
     */
    private StringBuffer cache = new StringBuffer(50);

    /**
     * Maximum validity period for the cache. Typically 1, use cache for duplicate
     * requests only, or 1000, use cache for requests within the same integral
     * second.
     */
    private final int expiration;

    /**
     * Date requested in previous conversion.
     */
    private long previousTime;

    /**
     * Scratch date object used to minimize date object creation.
     */
    private final Date tmpDate = new Date(0);

    /**
     * Creates a new CachedDateFormat object.
     * 
     * @param dateFormat Date format, may not be null.
     * @param expiration maximum cached range in milliseconds. If the dateFormat is
     *                   known to be incompatible with the caching algorithm, use a
     *                   value of 0 to totally disable caching or 1 to only use
     *                   cache for duplicate requests.
     */
    public CachedDateFormat(final DateFormat dateFormat, final int expiration) {
	if (dateFormat == null) {
	    throw new IllegalArgumentException("dateFormat cannot be null");
	}

	if (expiration < 0) {
	    throw new IllegalArgumentException("expiration must be non-negative");
	}

	formatter = dateFormat;
	this.expiration = expiration;
	millisecondStart = 0;

	//
	// set the previousTime so the cache will be invalid
	// for the next request.
	previousTime = Long.MIN_VALUE;
	slotBegin = Long.MIN_VALUE;
    }

    /**
     * Finds start of millisecond field in formatted time.
     * 
     * @param time      long time, must be integral number of seconds
     * @param formatted String corresponding formatted string
     * @param formatter DateFormat date format
     * @return int position in string of first digit of milliseconds, -1 indicates
     *         no millisecond field, -2 indicates unrecognized field (likely
     *         RelativeTimeDateFormat)
     */
    public static int findMillisecondStart(final long time, final String formatted, final DateFormat formatter) {
	long slotBegin = (time / 1000) * 1000;

	if (slotBegin > time) {
	    slotBegin -= 1000;
	}

	int millis = (int) (time - slotBegin);

	int magic = MAGIC1;
	String magicString = MAGICSTRING1;

	if (millis == MAGIC1) {
	    magic = MAGIC2;
	    magicString = MAGICSTRING2;
	}

	String plusMagic = formatter.format(new Date(slotBegin + magic));

	/**
	 * If the string lengths differ then we can't use the cache except for duplicate
	 * requests.
	 */
	if (plusMagic.length() != formatted.length()) {
	    return UNRECOGNIZED_MILLISECONDS;
	} else {
	    // find first difference between values
	    for (int i = 0; i < formatted.length(); i++) {
		if (formatted.charAt(i) != plusMagic.charAt(i)) {
		    //
		    // determine the expected digits for the base time
		    StringBuffer formattedMillis = new StringBuffer("ABC");
		    millisecondFormat(millis, formattedMillis, 0);

		    String plusZero = formatter.format(new Date(slotBegin));

		    // If the next 3 characters match the magic
		    // string and the expected string
		    if ((plusZero.length() == formatted.length())
			    && magicString.regionMatches(0, plusMagic, i, magicString.length())
			    && formattedMillis.toString().regionMatches(0, formatted, i, magicString.length())
			    && ZERO_STRING.regionMatches(0, plusZero, i, ZERO_STRING.length())) {
			return i;
		    } else {
			return UNRECOGNIZED_MILLISECONDS;
		    }
		}
	    }
	}

	return NO_MILLISECONDS;
    }

    /**
     * Formats a Date into a date/time string.
     *
     * @param date          the date to format.
     * @param sbuf          the string buffer to write to.
     * @param fieldPosition remains untouched.
     * @return the formatted time string.
     */
    public StringBuffer format(Date date, StringBuffer sbuf, FieldPosition fieldPosition) {
	format(date.getTime(), sbuf);

	return sbuf;
    }

    /**
     * Formats a millisecond count into a date/time string.
     *
     * @param now Number of milliseconds after midnight 1 Jan 1970 GMT.
     * @param buf the string buffer to write to.
     * @return the formatted time string.
     */
    public StringBuffer format(long now, StringBuffer buf) {
	//
	// If the current requested time is identical to the previously
	// requested time, then append the cache contents.
	//
	if (now == previousTime) {
	    buf.append(cache);

	    return buf;
	}

	//
	// If millisecond pattern was not unrecognized
	// (that is if it was found or milliseconds did not appear)
	//
	if (millisecondStart != UNRECOGNIZED_MILLISECONDS &&
	// Check if the cache is still valid.
	// If the requested time is within the same integral second
	// as the last request and a shorter expiration was not requested.
		(now < (slotBegin + expiration)) && (now >= slotBegin) && (now < (slotBegin + 1000L))) {
	    //
	    // if there was a millisecond field then update it
	    //
	    if (millisecondStart >= 0) {
		millisecondFormat((int) (now - slotBegin), cache, millisecondStart);
	    }

	    //
	    // update the previously requested time
	    // (the slot begin should be unchanged)
	    previousTime = now;
	    buf.append(cache);

	    return buf;
	}

	//
	// could not use previous value.
	// Call underlying formatter to format date.
	cache.setLength(0);
	tmpDate.setTime(now);
	cache.append(formatter.format(tmpDate));
	buf.append(cache);
	previousTime = now;
	slotBegin = (previousTime / 1000) * 1000;

	if (slotBegin > previousTime) {
	    slotBegin -= 1000;
	}

	//
	// if the milliseconds field was previous found
	// then reevaluate in case it moved.
	//
	if (millisecondStart >= 0) {
	    millisecondStart = findMillisecondStart(now, cache.toString(), formatter);
	}

	return buf;
    }

    /**
     * Formats a count of milliseconds (0-999) into a numeric representation.
     * 
     * @param millis Millisecond coun between 0 and 999.
     * @param buf    String buffer, may not be null.
     * @param offset Starting position in buffer, the length of the buffer must be
     *               at least offset + 3.
     */
    private static void millisecondFormat(final int millis, final StringBuffer buf, final int offset) {
	buf.setCharAt(offset, DIGITS.charAt(millis / 100));
	buf.setCharAt(offset + 1, DIGITS.charAt((millis / 10) % 10));
	buf.setCharAt(offset + 2, DIGITS.charAt(millis % 10));
    }

    /**
     * Set timezone.
     *
     * Setting the timezone using getCalendar().setTimeZone() will likely cause
     * caching to misbehave.
     * 
     * @param timeZone TimeZone new timezone
     */
    public void setTimeZone(final TimeZone timeZone) {
	formatter.setTimeZone(timeZone);
	previousTime = Long.MIN_VALUE;
	slotBegin = Long.MIN_VALUE;
    }

    /**
     * This method is delegated to the formatter which most likely returns null.
     * 
     * @param s   string representation of date.
     * @param pos field position, unused.
     * @return parsed date, likely null.
     */
    public Date parse(String s, ParsePosition pos) {
	return formatter.parse(s, pos);
    }

    /**
     * Gets number formatter.
     *
     * @return NumberFormat number formatter
     */
    public NumberFormat getNumberFormat() {
	return formatter.getNumberFormat();
    }

    /**
     * Gets maximum cache validity for the specified SimpleDateTime conversion
     * pattern.
     * 
     * @param pattern conversion pattern, may not be null.
     * @return Duration in milliseconds from an integral second that the cache will
     *         return consistent results.
     */
    public static int getMaximumCacheValidity(final String pattern) {
	//
	// If there are more "S" in the pattern than just one "SSS" then
	// (for example, "HH:mm:ss,SSS SSS"), then set the expiration to
	// one millisecond which should only perform duplicate request caching.
	//
	int firstS = pattern.indexOf('S');

	if ((firstS >= 0) && (firstS != pattern.lastIndexOf("SSS"))) {
	    return 1;
	}

	return 1000;
    }
}
