/*
 * 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.wicket.util.string;

import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.text.NumberFormat;
import java.text.ParseException;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.Locale;

import org.apache.wicket.util.io.IClusterable;
import org.apache.wicket.util.lang.Args;
import org.apache.wicket.util.lang.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * Holds an immutable String value and optionally a Locale, with methods to convert to various
 * types. Also provides some handy parsing methods and a variety of static factory methods.
 * <p>
 * Objects can be constructed directly from Strings or by using the valueOf() static factory
 * methods. The repeat() static factory methods provide a way of generating a String value that
 * repeats a given char or String a number of times.
 * <p>
 * Conversions to a wide variety of types can be found in the to*() methods. A generic conversion
 * can be achieved with to(Class).
 * <P>
 * The beforeFirst(), afterFirst(), beforeLast() and afterLast() methods are handy for parsing
 * things like paths and filenames.
 * 
 * @author Jonathan Locke
 */
public class StringValue implements IClusterable
{
	private static final long serialVersionUID = 1L;

	private static final Logger LOG = LoggerFactory.getLogger(StringValue.class);

	/** Locale to be used for formatting and parsing. */
	private final Locale locale;

	/** The underlying string. */
	private final String text;

	/**
	 * @param times
	 *            Number of times to repeat character
	 * @param c
	 *            Character to repeat
	 * @return Repeated character string
	 */
	public static StringValue repeat(final int times, final char c)
	{
		final AppendingStringBuffer buffer = new AppendingStringBuffer(times);

		for (int i = 0; i < times; i++)
		{
			buffer.append(c);
		}

		return valueOf(buffer);
	}

	/**
	 * @param times
	 *            Number of times to repeat string
	 * @param s
	 *            String to repeat
	 * @return Repeated character string
	 */
	public static StringValue repeat(final int times, final String s)
	{
		final AppendingStringBuffer buffer = new AppendingStringBuffer(times);

		for (int i = 0; i < times; i++)
		{
			buffer.append(s);
		}

		return valueOf(buffer);
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param value
	 *            Double precision value
	 * @return String value formatted with one place after decimal
	 */
	public static StringValue valueOf(final double value)
	{
		return valueOf(value, Locale.getDefault(Locale.Category.FORMAT));
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param value
	 *            Double precision value
	 * @param places
	 *            Number of places after decimal
	 * @param locale
	 *            Locale to be used for formatting
	 * @return String value formatted with the given number of places after decimal
	 */
	public static StringValue valueOf(final double value, final int places, final Locale locale)
	{
		if (Double.isNaN(value) || Double.isInfinite(value))
		{
			return valueOf("N/A");
		}
		else
		{
			final DecimalFormat format = new DecimalFormat("#." + repeat(places, '#'),
				new DecimalFormatSymbols(locale));
			return valueOf(format.format(value));
		}
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param value
	 *            Double precision value
	 * @param locale
	 *            Locale to be used for formatting
	 * @return String value formatted with one place after decimal
	 */
	public static StringValue valueOf(final double value, final Locale locale)
	{
		return valueOf(value, 1, locale);
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param object
	 *            An object
	 * @return String value for object
	 */
	public static StringValue valueOf(final Object object)
	{
		return valueOf(Strings.toString(object));
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param object
	 *            An object
	 * @param locale
	 *            Locale to be used for formatting
	 * @return String value for object
	 */
	public static StringValue valueOf(final Object object, final Locale locale)
	{
		return valueOf(Strings.toString(object), locale);
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param string
	 *            A string
	 * @return String value for string
	 */
	public static StringValue valueOf(final String string)
	{
		return new StringValue(string);
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param string
	 *            A string
	 * @param locale
	 *            Locale to be used for formatting
	 * @return String value for string
	 */
	public static StringValue valueOf(final String string, final Locale locale)
	{
		return new StringValue(string, locale);
	}

	/**
	 * Converts the given input to an instance of StringValue.
	 * 
	 * @param buffer
	 *            A string buffer
	 * @return String value
	 */
	public static StringValue valueOf(final AppendingStringBuffer buffer)
	{
		return valueOf(buffer.toString());
	}

	/**
	 * Private constructor to force use of static factory methods.
	 * 
	 * @param text
	 *            The text for this string value
	 */
	protected StringValue(final String text)
	{
		this(text, Locale.getDefault());
	}

	/**
	 * Private constructor to force use of static factory methods.
	 * 
	 * @param text
	 *            The text for this string value
	 * @param locale
	 *            the locale for formatting and parsing
	 */
	protected StringValue(final String text, final Locale locale)
	{
		this.text = text;
		this.locale = locale;
	}

	/**
	 * Gets the substring after the first occurrence given char.
	 * 
	 * @param c
	 *            char to scan for
	 * @return the substring
	 */
	public final String afterFirst(final char c)
	{
		return Strings.afterFirst(text, c);
	}

	/**
	 * Gets the substring after the last occurrence given char.
	 * 
	 * @param c
	 *            char to scan for
	 * @return the substring
	 */
	public final String afterLast(final char c)
	{
		return Strings.afterLast(text, c);
	}

	/**
	 * Gets the substring before the first occurrence given char.
	 * 
	 * @param c
	 *            char to scan for
	 * @return the substring
	 */
	public final String beforeFirst(final char c)
	{
		return Strings.beforeFirst(text, c);
	}

	/**
	 * Gets the substring before the last occurrence given char.
	 * 
	 * @param c
	 *            char to scan for
	 * @return the substring
	 */
	public final String beforeLast(final char c)
	{
		return Strings.afterLast(text, c);
	}

	/**
	 * Replaces on this text.
	 * 
	 * @param searchFor
	 *            What to search for
	 * @param replaceWith
	 *            What to replace with
	 * @return This string value with searchFor replaces with replaceWith
	 */
	public final CharSequence replaceAll(final CharSequence searchFor,
		final CharSequence replaceWith)
	{
		return Strings.replaceAll(text, searchFor, replaceWith);
	}

	/**
	 * Converts this StringValue to a given type.
	 * 
	 * @param type
	 *            The type to convert to
	 * @return The converted value
	 * @throws StringValueConversionException
	 */
	@SuppressWarnings({ "unchecked", "rawtypes" })
	public final <T> T to(final Class<T> type) throws StringValueConversionException
	{
		if (type == null)
		{
			return null;
		}

		if (type == String.class)
		{
			return (T)toString();
		}

		if ((type == Integer.TYPE) || (type == Integer.class))
		{
			return (T)toInteger();
		}

		if ((type == Long.TYPE) || (type == Long.class))
		{
			return (T)toLongObject();
		}

		if ((type == Boolean.TYPE) || (type == Boolean.class))
		{
			return (T)toBooleanObject();
		}

		if ((type == Double.TYPE) || (type == Double.class))
		{
			return (T)toDoubleObject();
		}

		if ((type == Character.TYPE) || (type == Character.class))
		{
			return (T)toCharacter();
		}

		if (type == Instant.class)
		{
			return (T)toInstant();
		}

		if (type == Duration.class)
		{
			return (T)toDuration();
		}

		if (type.isEnum())
		{
			return (T)toEnum((Class)type);
		}

		throw new StringValueConversionException(
			"Cannot convert '" + toString() + "'to type " + type);
	}

	/**
	 * Converts this StringValue to a given type or {@code null} if the value is empty.
	 * 
	 * @param type
	 *            The type to convert to
	 * @return The converted value
	 * @throws StringValueConversionException
	 */
	public final <T> T toOptional(final Class<T> type) throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : to(type);
	}

	/**
	 * Convert this text to a boolean.
	 * 
	 * @return This string value as a boolean
	 * @throws StringValueConversionException
	 */
	public final boolean toBoolean() throws StringValueConversionException
	{
		return Strings.isTrue(text);
	}

	/**
	 * Convert to boolean, returning default value if text is inconvertible.
	 * 
	 * @param defaultValue
	 *            the default value
	 * @return the converted text as a boolean or the default value if text is empty or
	 *         inconvertible
	 * @see Strings#isTrue(String)
	 */
	public final boolean toBoolean(final boolean defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toBoolean();
			}
			catch (StringValueConversionException x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(
						String.format("An error occurred while converting '%s' to a boolean: %s",
							text, x.getMessage()),
						x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert this text to a boolean.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final Boolean toBooleanObject() throws StringValueConversionException
	{
		return Strings.toBoolean(text);
	}

	/**
	 * Convert this text to a char.
	 * 
	 * @return This string value as a character
	 * @throws StringValueConversionException
	 */
	public final char toChar() throws StringValueConversionException
	{
		return Strings.toChar(text);
	}

	/**
	 * Convert to character, returning default value if text is inconvertible.
	 * 
	 * @param defaultValue
	 *            the default value
	 * @return the converted text as a primitive char or the default value if text is not a single
	 *         character
	 */
	public final char toChar(final char defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toChar();
			}
			catch (StringValueConversionException x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(
						String.format("An error occurred while converting '%s' to a character: %s",
							text, x.getMessage()),
						x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert this text to a Character.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final Character toCharacter() throws StringValueConversionException
	{
		return toChar();
	}

	/**
	 * Convert this text to a double.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final double toDouble() throws StringValueConversionException
	{
		try
		{
			return NumberFormat.getNumberInstance(locale).parse(text).doubleValue();
		}
		catch (ParseException e)
		{
			throw new StringValueConversionException(
				"Unable to convert '" + text + "' to a double value", e);
		}
	}

	/**
	 * Convert to double, returning default value if text is inconvertible.
	 * 
	 * @param defaultValue
	 *            the default value
	 * @return the converted text as a double or the default value if text is empty or inconvertible
	 */
	public final double toDouble(final double defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toDouble();
			}
			catch (Exception x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(
						String.format("An error occurred while converting '%s' to a double: %s",
							text, x.getMessage()),
						x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert this text to a Double.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final Double toDoubleObject() throws StringValueConversionException
	{
		return toDouble();
	}

	/**
	 * Convert this text to a Duration instance.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 * @see Duration#parse(CharSequence)
	 */
	public final Duration toDuration() throws StringValueConversionException
	{
		try
		{
			return Duration.parse(text);
		}
		catch (Exception e)
		{
			throw new StringValueConversionException("Unable to convert '" + text + "' to a Duration value", e);
		}
	}

	/**
	 * Convert to duration, returning default value if text is inconvertible.
	 * 
	 * @param defaultValue
	 *            the default value
	 * @return the converted text as a duration or the default value if text is empty or
	 *         inconvertible
	 * @see Duration#parse(CharSequence)
	 */
	public final Duration toDuration(final Duration defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toDuration();
			}
			catch (Exception x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(
						String.format("An error occurred while converting '%s' to a Duration: %s",
							text, x.getMessage()),
						x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert this text to an int.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final int toInt() throws StringValueConversionException
	{
		try
		{
			return Integer.parseInt(text);
		}
		catch (NumberFormatException e)
		{
			throw new StringValueConversionException(
				"Unable to convert '" + text + "' to an int value", e);
		}
	}

	/**
	 * Convert to integer, returning default value if text is inconvertible.
	 * 
	 * @param defaultValue
	 *            the default value
	 * @return the converted text as an integer or the default value if text is not an integer
	 */
	public final int toInt(final int defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toInt();
			}
			catch (StringValueConversionException x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(
						String.format("An error occurred while converting '%s' to an integer: %s",
							text, x.getMessage()),
						x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert this text to an Integer.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final Integer toInteger() throws StringValueConversionException
	{
		try
		{
			return Integer.parseInt(text, 10);
		}
		catch (NumberFormatException e)
		{
			throw new StringValueConversionException(
				"Unable to convert '" + text + "' to an Integer value", e);
		}
	}

	/**
	 * Convert this text to a long.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final long toLong() throws StringValueConversionException
	{
		try
		{
			return Long.parseLong(text);
		}
		catch (NumberFormatException e)
		{
			throw new StringValueConversionException(
				"Unable to convert '" + text + "' to a long value", e);
		}
	}

	/**
	 * Convert to long integer, returning default value if text is inconvertible.
	 * 
	 * @param defaultValue
	 *            the default value
	 * @return the converted text as a long integer or the default value if text is empty or
	 *         inconvertible
	 */
	public final long toLong(final long defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toLong();
			}
			catch (StringValueConversionException x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(String.format("An error occurred while converting '%s' to a long: %s",
						text, x.getMessage()), x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert this text to a Long.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final Long toLongObject() throws StringValueConversionException
	{
		try
		{
			return Long.parseLong(text, 10);
		}
		catch (NumberFormatException e)
		{
			throw new StringValueConversionException(
				"Unable to convert '" + text + "' to a Long value", e);
		}
	}

	/**
	 * Convert to object types, returning null if text is null or empty.
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final Boolean toOptionalBoolean() throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toBooleanObject();
	}

	/**
	 * Convert to object types, returning null if text is null or empty.
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final Character toOptionalCharacter() throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toCharacter();
	}

	/**
	 * Convert to object types, returning null if text is null or empty.
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final Double toOptionalDouble() throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toDoubleObject();
	}

	/**
	 * Convert to object types, returning null if text is null or empty.
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final Duration toOptionalDuration() throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toDuration();
	}

	/**
	 * Convert to object types, returning null if text is null or empty.
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final Integer toOptionalInteger() throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toInteger();
	}

	/**
	 * Convert to object types, returning null if text is null or empty.
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final Long toOptionalLong() throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toLongObject();
	}

	/**
	 * Convert to object types, returning null if text is null.
	 * 
	 * @return converted
	 */
	public final String toOptionalString()
	{
		return text;
	}

	/**
	 * Convert to object types, returning null if text is null or empty.
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final Instant toOptionalInstant() throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toInstant();
	}

	/**
	 * @return The string value
	 */
	@Override
	public final String toString()
	{
		return text;
	}

	/**
	 * Convert to primitive types, returning default value if text is null.
	 * 
	 * @param defaultValue
	 *            the default value to return of text is null
	 * @return the converted text as a primitive or the default if text is null
	 */
	public final String toString(final String defaultValue)
	{
		return (text == null) ? defaultValue : text;
	}

	/**
	 * Convert this text to an {@link Instant} instance.
	 * 
	 * @return Converted text
	 * @throws StringValueConversionException
	 */
	public final Instant toInstant() throws StringValueConversionException
	{
		try
		{
			return Instant.parse(text);
		}
		catch (DateTimeParseException e)
		{
			throw new StringValueConversionException(
				"Unable to convert '" + text + "' to a Instant value", e);
		}
	}

	/**
	 * Convert to {@link Instant}, returning default value if text is inconvertible.
	 * 
	 * @param defaultValue
	 *            the default value
	 * @return the converted text as a {@link Instant} or the default value if text is inconvertible.
	 */
	public final Instant toInstant(final Instant defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toInstant();
			}
			catch (StringValueConversionException x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(String.format("An error occurred while converting '%s' to an Instant: %s",
						text, x.getMessage()), x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert this text to an enum.
	 * 
	 * @param eClass
	 *            enum type
	 * @return The value as an enum
	 * @throws StringValueConversionException
	 */
	public final <T extends Enum<T>> T toEnum(Class<T> eClass) throws StringValueConversionException
	{
		return Strings.toEnum(text, eClass);
	}

	/**
	 * Convert this text to an enum.
	 * 
	 * @param defaultValue
	 *            This will be returned if there is an error converting the value
	 * @return The value as an enum
	 */
	@SuppressWarnings("unchecked")
	public final <T extends Enum<T>> T toEnum(final T defaultValue)
	{
		Args.notNull(defaultValue, "defaultValue");
		return toEnum((Class<T>)defaultValue.getClass(), defaultValue);
	}

	/**
	 * Convert this text to an enum.
	 * 
	 * @param eClass
	 *            enum type
	 * @param defaultValue
	 *            This will be returned if there is an error converting the value
	 * @return The value as an enum
	 */
	public final <T extends Enum<T>> T toEnum(Class<T> eClass, final T defaultValue)
	{
		if (text != null)
		{
			try
			{
				return toEnum(eClass);
			}
			catch (StringValueConversionException x)
			{
				if (LOG.isDebugEnabled())
				{
					LOG.debug(String.format("An error occurred while converting '%s' to a %s: %s",
						text, eClass, x.getMessage()), x);
				}
			}
		}
		return defaultValue;
	}

	/**
	 * Convert to enum, returning null if text is null or empty.
	 * 
	 * @param eClass
	 *            enum type
	 * 
	 * @return converted
	 * @throws StringValueConversionException
	 */
	public final <T extends Enum<T>> T toOptionalEnum(Class<T> eClass)
		throws StringValueConversionException
	{
		return Strings.isEmpty(text) ? null : toEnum(eClass);
	}

	/**
	 * Returns whether the text is null.
	 * 
	 * @return <code>true</code> if the text is <code>null</code>, <code>false</code> otherwise.
	 */
	public boolean isNull()
	{
		return text == null;
	}

	/**
	 * Returns whether the text is null or empty
	 * 
	 * @return <code>true</code> if the text is <code>null</code> or
	 *         <code>.trim().length()==0</code>, <code>false</code> otherwise.
	 */
	public boolean isEmpty()
	{
		return Strings.isEmpty(text);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public int hashCode()
	{
		return Objects.hashCode(locale, text);
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public boolean equals(final Object obj)
	{
		if (obj instanceof StringValue)
		{
			StringValue stringValue = (StringValue)obj;
			return Objects.isEqual(text, stringValue.text) && locale.equals(stringValue.locale);
		}
		else
		{
			return false;
		}
	}
}
