/*
 * 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.commons.configuration2.convert;

import java.awt.Color;
import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;

import org.apache.commons.configuration2.ex.ConversionException;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;

/**
 * A utility class to convert the configuration properties into any type.
 *
 * @since 1.1
 */
final class PropertyConverter
{

    /** Constant for the prefix of hex numbers.*/
    private static final String HEX_PREFIX = "0x";

    /** Constant for the radix of hex numbers.*/
    private static final int HEX_RADIX = 16;

    /** Constant for the prefix of binary numbers.*/
    private static final String BIN_PREFIX = "0b";

    /** Constant for the radix of binary numbers.*/
    private static final int BIN_RADIX = 2;

    /** Constant for the argument classes of the Number constructor that takes a String. */
    private static final Class<?>[] CONSTR_ARGS = {String.class};

    /** The fully qualified name of {@code javax.mail.internet.InternetAddress} */
    private static final String INTERNET_ADDRESS_CLASSNAME = "javax.mail.internet.InternetAddress";

    /**
     * Private constructor prevents instances from being created.
     */
    private PropertyConverter()
    {
        // to prevent instantiation...
    }

    /**
     * Performs a data type conversion from the specified value object to the
     * given target data class. If additional information is required for this
     * conversion, it is obtained from the passed in
     * {@code DefaultConversionHandler} object. If the class is a primitive type
     * (Integer.TYPE, Boolean.TYPE, etc), the value returned will use the
     * wrapper type (Integer.class, Boolean.class, etc).
     *
     * @param cls the target class of the converted value
     * @param value the value to convert
     * @param convHandler the conversion handler object
     * @return the converted value
     * @throws ConversionException if the value is not compatible with the
     *         requested type
     */
    public static Object to(final Class<?> cls, final Object value,
            final DefaultConversionHandler convHandler) throws ConversionException
    {
        if (cls.isInstance(value))
        {
            return value; // no conversion needed
        }

        if (String.class.equals(cls))
        {
            return String.valueOf(value);
        }
        if (Boolean.class.equals(cls) || Boolean.TYPE.equals(cls))
        {
            return toBoolean(value);
        }
        else if (Character.class.equals(cls) || Character.TYPE.equals(cls))
        {
            return toCharacter(value);
        }
        else if (Number.class.isAssignableFrom(cls) || cls.isPrimitive())
        {
            if (Integer.class.equals(cls) || Integer.TYPE.equals(cls))
            {
                return toInteger(value);
            }
            else if (Long.class.equals(cls) || Long.TYPE.equals(cls))
            {
                return toLong(value);
            }
            else if (Byte.class.equals(cls) || Byte.TYPE.equals(cls))
            {
                return toByte(value);
            }
            else if (Short.class.equals(cls) || Short.TYPE.equals(cls))
            {
                return toShort(value);
            }
            else if (Float.class.equals(cls) || Float.TYPE.equals(cls))
            {
                return toFloat(value);
            }
            else if (Double.class.equals(cls) || Double.TYPE.equals(cls))
            {
                return toDouble(value);
            }
            else if (BigInteger.class.equals(cls))
            {
                return toBigInteger(value);
            }
            else if (BigDecimal.class.equals(cls))
            {
                return toBigDecimal(value);
            }
        }
        else if (Date.class.equals(cls))
        {
            return toDate(value, convHandler.getDateFormat());
        }
        else if (Calendar.class.equals(cls))
        {
            return toCalendar(value, convHandler.getDateFormat());
        }
        else if (File.class.equals(cls))
        {
            return toFile(value);
        }
        else if (Path.class.equals(cls))
        {
            return toPath(value);
        }
        else if (URI.class.equals(cls))
        {
            return toURI(value);
        }
        else if (URL.class.equals(cls))
        {
            return toURL(value);
        }
        else if (Pattern.class.equals(cls))
        {
            return toPattern(value);
        }
        else if (Locale.class.equals(cls))
        {
            return toLocale(value);
        }
        else if (isEnum(cls))
        {
            return convertToEnum(cls, value);
        }
        else if (Color.class.equals(cls))
        {
            return toColor(value);
        }
        else if (cls.getName().equals(INTERNET_ADDRESS_CLASSNAME))
        {
            return toInternetAddress(value);
        }
        else if (InetAddress.class.isAssignableFrom(cls))
        {
            return toInetAddress(value);
        }

        throw new ConversionException("The value '" + value + "' (" + value.getClass() + ")"
                + " can't be converted to a " + cls.getName() + " object");
    }

    /**
     * Convert the specified object into a Boolean. Internally the
     * {@code org.apache.commons.lang.BooleanUtils} class from the
     * <a href="https://commons.apache.org/lang/">Commons Lang</a>
     * project is used to perform this conversion. This class accepts some more
     * tokens for the boolean value of <b>true</b>, e.g. {@code yes} and
     * {@code on}. Please refer to the documentation of this class for more
     * details.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a boolean
     */
    public static Boolean toBoolean(final Object value) throws ConversionException
    {
        if (value instanceof Boolean)
        {
            return (Boolean) value;
        }
        else if (value instanceof String)
        {
            final Boolean b = BooleanUtils.toBooleanObject((String) value);
            if (b == null)
            {
                throw new ConversionException("The value " + value + " can't be converted to a Boolean object");
            }
            return b;
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a Boolean object");
        }
    }

    /**
     * Converts the specified value object to a {@code Character}. This method
     * converts the passed in object to a string. If the string has exactly one
     * character, this character is returned as result. Otherwise, conversion
     * fails.
     *
     * @param value the value to be converted
     * @return the resulting {@code Character} object
     * @throws ConversionException if the conversion is not possible
     */
    public static Character toCharacter(final Object value) throws ConversionException
    {
        final String strValue = String.valueOf(value);
        if (strValue.length() == 1)
        {
            return Character.valueOf(strValue.charAt(0));
        }
        throw new ConversionException(
                String.format(
                        "The value '%s' cannot be converted to a Character object!",
                        strValue));
    }

    /**
     * Convert the specified object into a Byte.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a byte
     */
    public static Byte toByte(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, Byte.class);
        if (n instanceof Byte)
        {
            return (Byte) n;
        }
        return n.byteValue();
    }

    /**
     * Convert the specified object into a Short.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a short
     */
    public static Short toShort(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, Short.class);
        if (n instanceof Short)
        {
            return (Short) n;
        }
        return n.shortValue();
    }

    /**
     * Convert the specified object into an Integer.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to an integer
     */
    public static Integer toInteger(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, Integer.class);
        if (n instanceof Integer)
        {
            return (Integer) n;
        }
        return n.intValue();
    }

    /**
     * Convert the specified object into a Long.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Long
     */
    public static Long toLong(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, Long.class);
        if (n instanceof Long)
        {
            return (Long) n;
        }
        return n.longValue();
    }

    /**
     * Convert the specified object into a Float.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Float
     */
    public static Float toFloat(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, Float.class);
        if (n instanceof Float)
        {
            return (Float) n;
        }
        return new Float(n.floatValue());
    }

    /**
     * Convert the specified object into a Double.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Double
     */
    public static Double toDouble(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, Double.class);
        if (n instanceof Double)
        {
            return (Double) n;
        }
        return new Double(n.doubleValue());
    }

    /**
     * Convert the specified object into a BigInteger.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a BigInteger
     */
    public static BigInteger toBigInteger(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, BigInteger.class);
        if (n instanceof BigInteger)
        {
            return (BigInteger) n;
        }
        return BigInteger.valueOf(n.longValue());
    }

    /**
     * Convert the specified object into a BigDecimal.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a BigDecimal
     */
    public static BigDecimal toBigDecimal(final Object value) throws ConversionException
    {
        final Number n = toNumber(value, BigDecimal.class);
        if (n instanceof BigDecimal)
        {
            return (BigDecimal) n;
        }
        return new BigDecimal(n.doubleValue());
    }

    /**
     * Tries to convert the specified object into a number object. This method
     * is used by the conversion methods for number types. Note that the return
     * value is not in always of the specified target class, but only if a new
     * object has to be created.
     *
     * @param value the value to be converted (must not be <b>null</b>)
     * @param targetClass the target class of the conversion (must be derived
     * from {@code java.lang.Number})
     * @return the converted number
     * @throws ConversionException if the object cannot be converted
     */
    static Number toNumber(final Object value, final Class<?> targetClass) throws ConversionException
    {
        if (value instanceof Number)
        {
            return (Number) value;
        }
        final String str = value.toString();
        if (str.startsWith(HEX_PREFIX))
        {
            try
            {
                return new BigInteger(str.substring(HEX_PREFIX.length()), HEX_RADIX);
            }
            catch (final NumberFormatException nex)
            {
                throw new ConversionException("Could not convert " + str
                        + " to " + targetClass.getName()
                        + "! Invalid hex number.", nex);
            }
        }

        if (str.startsWith(BIN_PREFIX))
        {
            try
            {
                return new BigInteger(str.substring(BIN_PREFIX.length()), BIN_RADIX);
            }
            catch (final NumberFormatException nex)
            {
                throw new ConversionException("Could not convert " + str
                        + " to " + targetClass.getName()
                        + "! Invalid binary number.", nex);
            }
        }

        try
        {
            final Constructor<?> constr = targetClass.getConstructor(CONSTR_ARGS);
            return (Number) constr.newInstance(str);
        }
        catch (final InvocationTargetException itex)
        {
            throw new ConversionException("Could not convert " + str
                    + " to " + targetClass.getName(), itex
                    .getTargetException());
        }
        catch (final Exception ex)
        {
            // Treat all possible exceptions the same way
            throw new ConversionException(
                    "Conversion error when trying to convert " + str
                            + " to " + targetClass.getName(), ex);
        }
    }

    /**
     * Convert the specified object into a File.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a File
     * @since 2.3
     */
    public static File toFile(final Object value) throws ConversionException
    {
        if (value instanceof File)
        {
            return (File) value;
        }
        else if (value instanceof Path)
        {
            return ((Path) value).toFile();
        }
        else if (value instanceof String)
        {
            return new File((String) value);
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a File");
        }
    }

    /**
     * Convert the specified object into a Path.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Path
     * @since 2.3
     */
    public static Path toPath(final Object value) throws ConversionException
    {
        if (value instanceof File)
        {
            return ((File) value).toPath();
        }
        else if (value instanceof Path)
        {
            return (Path) value;
        }
        else if (value instanceof String)
        {
            return Paths.get((String) value);
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a Path");
        }
    }

    /**
     * Convert the specified object into an URI.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to an URI
     */
    public static URI toURI(final Object value) throws ConversionException
    {
        if (value instanceof URI)
        {
            return (URI) value;
        }
        else if (value instanceof String)
        {
            try
            {
                return new URI((String) value);
            }
            catch (final URISyntaxException e)
            {
                throw new ConversionException("The value " + value + " can't be converted to an URI", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to an URI");
        }
    }

    /**
     * Convert the specified object into an URL.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to an URL
     */
    public static URL toURL(final Object value) throws ConversionException
    {
        if (value instanceof URL)
        {
            return (URL) value;
        }
        else if (value instanceof String)
        {
            try
            {
                return new URL((String) value);
            }
            catch (final MalformedURLException e)
            {
                throw new ConversionException("The value " + value + " can't be converted to an URL", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to an URL");
        }
    }

    /**
     * Convert the specified object into a Pattern.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Pattern
     */
    public static Pattern toPattern(final Object value) throws ConversionException
    {
        if (value instanceof Pattern)
        {
            return (Pattern) value;
        }
        else if (value instanceof String)
        {
            try
            {
                return Pattern.compile((String) value);
            }
            catch (final PatternSyntaxException e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a Pattern", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a Pattern");
        }
    }

    /**
     * Convert the specified object into a Locale.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Locale
     */
    public static Locale toLocale(final Object value) throws ConversionException
    {
        if (value instanceof Locale)
        {
            return (Locale) value;
        }
        else if (value instanceof String)
        {
            final String[] elements = ((String) value).split("_");
            final int size = elements.length;

            if (size >= 1 && (elements[0].length() == 2 || elements[0].length() == 0))
            {
                final String language = elements[0];
                final String country = size >= 2 ? elements[1] : "";
                final String variant = size >= 3 ? elements[2] : "";

                return new Locale(language, country, variant);
            }
            throw new ConversionException("The value " + value + " can't be converted to a Locale");
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a Locale");
        }
    }

    /**
     * Convert the specified object into a Color. If the value is a String,
     * the format allowed is (#)?[0-9A-F]{6}([0-9A-F]{2})?. Examples:
     * <ul>
     *   <li>FF0000 (red)</li>
     *   <li>0000FFA0 (semi transparent blue)</li>
     *   <li>#CCCCCC (gray)</li>
     *   <li>#00FF00A0 (semi transparent green)</li>
     * </ul>
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Color
     */
    public static Color toColor(final Object value) throws ConversionException
    {
        if (value instanceof Color)
        {
            return (Color) value;
        }
        else if (value instanceof String && !StringUtils.isBlank((String) value))
        {
            String color = ((String) value).trim();

            final int[] components = new int[3];

            // check the size of the string
            final int minlength = components.length * 2;
            if (color.length() < minlength)
            {
                throw new ConversionException("The value " + value + " can't be converted to a Color");
            }

            // remove the leading #
            if (color.startsWith("#"))
            {
                color = color.substring(1);
            }

            try
            {
                // parse the components
                for (int i = 0; i < components.length; i++)
                {
                    components[i] = Integer.parseInt(color.substring(2 * i, 2 * i + 2), HEX_RADIX);
                }

                // parse the transparency
                int alpha;
                if (color.length() >= minlength + 2)
                {
                    alpha = Integer.parseInt(color.substring(minlength, minlength + 2), HEX_RADIX);
                }
                else
                {
                    alpha = Color.black.getAlpha();
                }

                return new Color(components[0], components[1], components[2], alpha);
            }
            catch (final Exception e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a Color", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a Color");
        }
    }

    /**
     * Convert the specified value into an internet address.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a InetAddress
     *
     * @since 1.5
     */
    static InetAddress toInetAddress(final Object value) throws ConversionException
    {
        if (value instanceof InetAddress)
        {
            return (InetAddress) value;
        }
        else if (value instanceof String)
        {
            try
            {
                return InetAddress.getByName((String) value);
            }
            catch (final UnknownHostException e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a InetAddress", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a InetAddress");
        }
    }

    /**
     * Convert the specified value into an email address.
     *
     * @param value the value to convert
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to an email address
     *
     * @since 1.5
     */
    static Object toInternetAddress(final Object value) throws ConversionException
    {
        if (value.getClass().getName().equals(INTERNET_ADDRESS_CLASSNAME))
        {
            return value;
        }
        else if (value instanceof String)
        {
            try
            {
                final Constructor<?> ctor = Class.forName(INTERNET_ADDRESS_CLASSNAME)
                        .getConstructor(String.class);
                return ctor.newInstance(value);
            }
            catch (final Exception e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a InternetAddress", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a InternetAddress");
        }
    }

    /**
     * Calls Class.isEnum() on Java 5, returns false on older JRE.
     */
    static boolean isEnum(final Class<?> cls)
    {
        return cls.isEnum();
    }

    /**
     * Convert the specified value into a Java 5 enum.
     *
     * @param value the value to convert
     * @param cls   the type of the enumeration
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to an enumeration
     *
     * @since 1.5
     */
    static <E extends Enum<E>> E toEnum(final Object value, final Class<E> cls) throws ConversionException
    {
        if (value.getClass().equals(cls))
        {
            return cls.cast(value);
        }
        else if (value instanceof String)
        {
            try
            {
                return Enum.valueOf(cls, (String) value);
            }
            catch (final Exception e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a " + cls.getName());
            }
        }
        else if (value instanceof Number)
        {
            try
            {
                final E[] enumConstants = cls.getEnumConstants();
                return enumConstants[((Number) value).intValue()];
            }
            catch (final Exception e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a " + cls.getName());
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a " + cls.getName());
        }
    }

    /**
     * Convert the specified object into a Date.
     *
     * @param value  the value to convert
     * @param format the DateFormat pattern to parse String values
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Calendar
     */
    public static Date toDate(final Object value, final String format) throws ConversionException
    {
        if (value instanceof Date)
        {
            return (Date) value;
        }
        else if (value instanceof Calendar)
        {
            return ((Calendar) value).getTime();
        }
        else if (value instanceof String)
        {
            try
            {
                return new SimpleDateFormat(format).parse((String) value);
            }
            catch (final ParseException e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a Date", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a Date");
        }
    }

    /**
     * Convert the specified object into a Calendar.
     *
     * @param value  the value to convert
     * @param format the DateFormat pattern to parse String values
     * @return the converted value
     * @throws ConversionException thrown if the value cannot be converted to a Calendar
     */
    public static Calendar toCalendar(final Object value, final String format) throws ConversionException
    {
        if (value instanceof Calendar)
        {
            return (Calendar) value;
        }
        else if (value instanceof Date)
        {
            final Calendar calendar = Calendar.getInstance();
            calendar.setTime((Date) value);
            return calendar;
        }
        else if (value instanceof String)
        {
            try
            {
                final Calendar calendar = Calendar.getInstance();
                calendar.setTime(new SimpleDateFormat(format).parse((String) value));
                return calendar;
            }
            catch (final ParseException e)
            {
                throw new ConversionException("The value " + value + " can't be converted to a Calendar", e);
            }
        }
        else
        {
            throw new ConversionException("The value " + value + " can't be converted to a Calendar");
        }
    }

    /**
     * Helper method for converting a value to a constant of an enumeration
     * class.
     *
     * @param enumClass the enumeration class
     * @param value the value to be converted
     * @return the converted value
     */
    @SuppressWarnings("unchecked")
    // conversion is safe because we know that the class is an Enum class
    private static Object convertToEnum(final Class<?> enumClass, final Object value)
    {
        return toEnum(value, enumClass.asSubclass(Enum.class));
    }
}
