/*
 * 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.activemq.transport.amqp.client.util;

import javax.net.ssl.SSLContext;
import java.beans.BeanInfo;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;

/**
 * Utilities for properties
 */
public class PropertyUtil {

   /**
    * Creates a URI from the original URI and the given parameters.
    *
    * @param originalURI The URI whose current parameters are removed and replaced with the given remainder value.
    * @param params      The URI params that should be used to replace the current ones in the target.
    * @return a new URI that matches the original one but has its query options replaced with the given ones.
    * @throws URISyntaxException if the given URI is invalid.
    */
   public static URI replaceQuery(URI originalURI, Map<String, String> params) throws URISyntaxException {
      String s = createQueryString(params);
      if (s.isEmpty()) {
         s = null;
      }
      return replaceQuery(originalURI, s);
   }

   /**
    * Creates a URI with the given query, removing an previous query value from the given URI.
    *
    * @param uri   The source URI whose existing query is replaced with the newly supplied one.
    * @param query The new URI query string that should be appended to the given URI.
    * @return a new URI that is a combination of the original URI and the given query string
    * @throws URISyntaxException if the given URI is invalid.
    */
   public static URI replaceQuery(URI uri, String query) throws URISyntaxException {
      String schemeSpecificPart = uri.getRawSchemeSpecificPart();
      // strip existing query if any
      int questionMark = schemeSpecificPart.lastIndexOf("?");
      // make sure question mark is not within parentheses
      if (questionMark < schemeSpecificPart.lastIndexOf(")")) {
         questionMark = -1;
      }
      if (questionMark > 0) {
         schemeSpecificPart = schemeSpecificPart.substring(0, questionMark);
      }
      if (query != null && !query.isEmpty()) {
         schemeSpecificPart += "?" + query;
      }
      return new URI(uri.getScheme(), schemeSpecificPart, uri.getFragment());
   }

   /**
    * Creates a URI with the given query, removing an previous query value from the given URI.
    *
    * @param uri The source URI whose existing query is replaced with the newly supplied one.
    * @return a new URI that is a combination of the original URI and the given query string
    * @throws URISyntaxException if the given URI is invalid.
    */
   public static URI eraseQuery(URI uri) throws URISyntaxException {
      return replaceQuery(uri, (String) null);
   }

   /**
    * Given a key / value mapping, create and return a URI formatted query string that is valid and can be appended to a
    * URI.
    *
    * @param options The Mapping that will create the new Query string.
    * @return a URI formatted query string
    */
   public static String createQueryString(Map<String, ?> options) {
      if (!options.isEmpty()) {
         StringBuilder rc = new StringBuilder();
         boolean first = true;
         for (Entry<String, ?> entry : options.entrySet()) {
            if (first) {
               first = false;
            } else {
               rc.append("&");
            }
            rc.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8));
            rc.append("=");
            rc.append(URLEncoder.encode((String) entry.getValue(), StandardCharsets.UTF_8));
         }
         return rc.toString();
      } else {
         return "";
      }
   }

   /**
    * Get properties from a URI and return them in a new {@code Map<String, String>} instance.
    * <p>
    * If the URI is null or the query string of the URI is null an empty Map is returned.
    *
    * @param uri the URI whose parameters are to be parsed.
    * @return {@code Map} of properties
    * @throws Exception if an error occurs while parsing the query options.
    */
   public static Map<String, String> parseParameters(URI uri) throws Exception {
      if (uri == null || uri.getQuery() == null) {
         return Collections.emptyMap();
      }

      return parseQuery(stripPrefix(uri.getQuery(), "?"));
   }

   /**
    * Parse properties from a named resource -eg. a URI or a simple name e.g. {@literal foo?name="fred"&size=2}
    *
    * @param uri the URI whose parameters are to be parsed.
    * @return {@code Map} of properties
    * @throws Exception if an error occurs while parsing the query options.
    */
   public static Map<String, String> parseParameters(String uri) throws Exception {
      if (uri == null) {
         return Collections.emptyMap();
      }

      return parseQuery(stripUpto(uri, '?'));
   }

   /**
    * Get properties from a URI query string.
    *
    * @param queryString the string value returned from a call to the URI class getQuery method.
    * @return {@code Map} of properties from the parsed string
    */
   public static Map<String, String> parseQuery(String queryString) {
      if (queryString != null && !queryString.isEmpty()) {
         Map<String, String> rc = new HashMap<>();
         for (String parameter : queryString.split("&")) {
            int p = parameter.indexOf("=");
            if (p >= 0) {
               String name = URLDecoder.decode(parameter.substring(0, p), StandardCharsets.UTF_8);
               String value = URLDecoder.decode(parameter.substring(p + 1), StandardCharsets.UTF_8);
               rc.put(name, value);
            } else {
               rc.put(parameter, null);
            }
         }
         return rc;
      }

      return Collections.emptyMap();
   }

   /**
    * Given a map of properties, filter out only those prefixed with the given value, the values filtered are returned
    * in a new Map instance.
    *
    * @param properties   The map of properties to filter.
    * @param optionPrefix The prefix value to use when filtering.
    * @return a filter map with only values that match the given prefix
    */
   public static Map<String, String> filterProperties(Map<String, String> properties, String optionPrefix) {
      if (properties == null) {
         throw new IllegalArgumentException("The given properties object was null.");
      }

      Map<String, String> rc = new HashMap<>(properties.size());

      for (Iterator<Entry<String, String>> iter = properties.entrySet().iterator(); iter.hasNext(); ) {
         Entry<String, String> entry = iter.next();
         if (entry.getKey().startsWith(optionPrefix)) {
            String name = entry.getKey().substring(optionPrefix.length());
            rc.put(name, entry.getValue());
            iter.remove();
         }
      }

      return rc;
   }

   /**
    * Enumerate the properties of the target object and add them as additional entries to the query string of the given
    * string URI.
    *
    * @param uri  The string URI value to append the object properties to.
    * @param bean The Object whose properties will be added to the target URI.
    * @return a new String value that is the original URI with the added bean properties
    * @throws Exception if an error occurs while enumerating the bean properties.
    */
   public static String addPropertiesToURIFromBean(String uri, Object bean) throws Exception {
      Map<String, String> properties = PropertyUtil.getProperties(bean);
      return PropertyUtil.addPropertiesToURI(uri, properties);
   }

   /**
    * Enumerate the properties of the target object and add them as additional entries to the query string of the given
    * URI.
    *
    * @param uri        The URI value to append the object properties to.
    * @param properties The Object whose properties will be added to the target URI.
    * @return a new String value that is the original URI with the added bean properties
    * @throws Exception if an error occurs while enumerating the bean properties.
    */
   public static String addPropertiesToURI(URI uri, Map<String, String> properties) throws Exception {
      return addPropertiesToURI(uri.toString(), properties);
   }

   /**
    * Append the given properties to the query portion of the given URI.
    *
    * @param uri        The string URI value to append the object properties to.
    * @param properties The properties that will be added to the target URI.
    * @return a new String value that is the original URI with the added properties
    * @throws Exception if an error occurs while building the new URI string.
    */
   public static String addPropertiesToURI(String uri, Map<String, String> properties) throws Exception {
      String result = uri;
      if (uri != null && properties != null) {
         StringBuilder base = new StringBuilder(stripBefore(uri, '?'));
         Map<String, String> map = parseParameters(uri);
         if (!map.isEmpty()) {
            map.putAll(properties);
         } else {
            map = properties;
         }
         if (!map.isEmpty()) {
            base.append('?');
            boolean first = true;
            for (Map.Entry<String, String> entry : map.entrySet()) {
               if (!first) {
                  base.append('&');
               }
               first = false;
               base.append(entry.getKey()).append("=").append(entry.getValue());
            }
            result = base.toString();
         }
      }
      return result;
   }

   /**
    * Set properties on an object using the provided map. The return value indicates if all properties from the given
    * map were set on the target object.
    *
    * @param target     the object whose properties are to be set from the map options.
    * @param properties the properties that should be applied to the given object.
    * @return true if all values in the properties map were applied to the target object
    */
   public static Map<String, String> setProperties(Object target, Map<String, String> properties) {
      if (target == null) {
         throw new IllegalArgumentException("target object cannot be null");
      }
      if (properties == null) {
         throw new IllegalArgumentException("Given Properties object cannot be null");
      }

      Map<String, String> unmatched = new HashMap<>();

      for (Map.Entry<String, String> entry : properties.entrySet()) {
         if (!setProperty(target, entry.getKey(), entry.getValue())) {
            unmatched.put(entry.getKey(), entry.getValue());
         }
      }

      return Collections.unmodifiableMap(unmatched);
   }

   //TODO: common impl for above and below methods.

   /**
    * Set properties on an object using the provided Properties object. The return value indicates if all properties
    * from the given map were set on the target object.
    *
    * @param target     the object whose properties are to be set from the map options.
    * @param properties the properties that should be applied to the given object.
    * @return an unmodifiable map with any values that could not be applied to the target
    */
   public static Map<String, Object> setProperties(Object target, Properties properties) {
      if (target == null) {
         throw new IllegalArgumentException("target object cannot be null");
      }
      if (properties == null) {
         throw new IllegalArgumentException("Given Properties object cannot be null");
      }

      Map<String, Object> unmatched = new HashMap<>();

      for (Map.Entry<Object, Object> entry : properties.entrySet()) {
         if (!setProperty(target, (String) entry.getKey(), entry.getValue())) {
            unmatched.put((String) entry.getKey(), entry.getValue());
         }
      }

      return Collections.<String, Object>unmodifiableMap(unmatched);
   }

   /**
    * Get properties from an object using reflection.  If the passed object is null an empty {@code Map} is returned.
    *
    * @param object the Object whose properties are to be extracted.
    * @return {@code Map} of properties extracted from the given object
    * @throws Exception if an error occurs while examining the object's properties.
    */
   public static Map<String, String> getProperties(Object object) throws Exception {
      if (object == null) {
         return Collections.emptyMap();
      }

      Map<String, String> properties = new LinkedHashMap<>();
      BeanInfo beanInfo = Introspector.getBeanInfo(object.getClass());
      Object[] NULL_ARG = {};
      PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors();
      if (propertyDescriptors != null) {
         for (PropertyDescriptor pd : propertyDescriptors) {
            if (pd.getReadMethod() != null && !pd.getName().equals("class") && !pd.getName().equals("properties") && !pd.getName().equals("reference")) {
               Object value = pd.getReadMethod().invoke(object, NULL_ARG);
               if (value != null) {
                  if (value instanceof Boolean || value instanceof Number || value instanceof String || value instanceof URI || value instanceof URL) {
                     properties.put(pd.getName(), ("" + value));
                  } else if (value instanceof SSLContext) {
                     // ignore this one..
                  } else {
                     Map<String, String> inner = getProperties(value);
                     for (Entry<String, String> entry : inner.entrySet()) {
                        properties.put(pd.getName() + "." + entry.getKey(), entry.getValue());
                     }
                  }
               }
            }
         }
      }

      return properties;
   }

   /**
    * Find a specific property getter in a given object based on a property name.
    *
    * @param object the object to search.
    * @param name   the property name to search for.
    * @return the result of invoking the specific property get method
    * @throws Exception if an error occurs while searching the object's bean info.
    */
   public static Object getProperty(Object object, String name) throws Exception {
      BeanInfo beanInfo = Introspector.getBeanInfo(object.getClass());
      PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors();
      if (propertyDescriptors != null) {
         for (PropertyDescriptor pd : propertyDescriptors) {
            if (pd.getReadMethod() != null && pd.getName().equals(name)) {
               return pd.getReadMethod().invoke(object);
            }
         }
      }
      return null;
   }

   /**
    * Set a property named property on a given Object.
    * <p>
    * The object is searched for an set method that would match the given named property and if one is found.  If
    * necessary an attempt will be made to convert the new value to an acceptable type.
    *
    * @param target The object whose property is to be set.
    * @param name   The name of the property to set.
    * @param value  The new value to set for the named property.
    * @return true if the property was able to be set on the target object
    */
   public static boolean setProperty(Object target, String name, Object value) {
      try {
         int dotPos = name.indexOf(".");
         while (dotPos >= 0) {
            String getterName = name.substring(0, dotPos);
            target = getProperty(target, getterName);
            name = name.substring(dotPos + 1);
            dotPos = name.indexOf(".");
         }

         Class<?> clazz = target.getClass();
         Method setter = findSetterMethod(clazz, name);
         if (setter == null) {
            return false;
         }
         // If the type is null or it matches the needed type, just use the
         // value directly
         if (value == null || value.getClass() == setter.getParameterTypes()[0]) {
            setter.invoke(target, new Object[]{value});
         } else {
            setter.invoke(target, new Object[]{convert(value, setter.getParameterTypes()[0])});
         }
         return true;
      } catch (Throwable ignore) {
         return false;
      }
   }

   /**
    * Return a String minus the given prefix.  If the string does not start with the given prefix the original string
    * value is returned.
    *
    * @param value  The String whose prefix is to be removed.
    * @param prefix The prefix string to remove from the target string.
    * @return stripped version of the original input string
    */
   public static String stripPrefix(String value, String prefix) {
      if (value != null && prefix != null && value.startsWith(prefix)) {
         return value.substring(prefix.length());
      }
      return value;
   }

   /**
    * Return a portion of a String value by looking beyond the given character.
    *
    * @param value The string value to split
    * @param c     The character that marks the split point.
    * @return the sub-string value starting beyond the given character
    */
   public static String stripUpto(String value, char c) {
      String result = null;
      if (value != null) {
         int index = value.indexOf(c);
         if (index > 0) {
            result = value.substring(index + 1);
         }
      }
      return result;
   }

   /**
    * Return a String up to and including character
    *
    * @param value The string value to split
    * @param c     The character that marks the start of split point.
    * @return the sub-string value starting from the given character
    */
   public static String stripBefore(String value, char c) {
      String result = value;
      if (value != null) {
         int index = value.indexOf(c);
         if (index > 0) {
            result = value.substring(0, index);
         }
      }
      return result;
   }

   private static Method findSetterMethod(Class<?> clazz, String name) {
      // Build the method name.
      name = "set" + name.substring(0, 1).toUpperCase() + name.substring(1);
      Method[] methods = clazz.getMethods();
      for (Method method : methods) {
         Class<?>[] params = method.getParameterTypes();
         if (method.getName().equals(name) && params.length == 1) {
            return method;
         }
      }
      return null;
   }

   private static Object convert(Object value, Class<?> type) throws Exception {
      if (value == null) {
         if (boolean.class.isAssignableFrom(type)) {
            return Boolean.FALSE;
         }
         return null;
      }

      if (type.isAssignableFrom(value.getClass())) {
         return type.cast(value);
      }

      // special for String[] as we do not want to use a PropertyEditor for that
      if (type.isAssignableFrom(String[].class)) {
         return StringArrayConverter.convertToStringArray(value);
      }

      if (type == URI.class) {
         return new URI(value.toString());
      }

      return TypeConversionSupport.convert(value, type);
   }
}
