/*
 * Copyright 2007 Ralf Joachim
 *
 * 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.
 *
 * $Id: Configuration.java 6907 2007-03-28 21:24:52Z rjoachim $
 */
package org.castor.core.util;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Abstract base class to hold Castor configuration properties.
 * 
 * @version $Id: Configuration.java,v 1.8 2006/03/08 17:25:52 jens Exp $
 * @author <a href="mailto:ralf DOT joachim AT syscon DOT eu">Ralf Joachim</a>
 * @since 1.1.3
 */
public abstract class AbstractProperties {

  /**
   * Name of the system property that can be used to specify the location of user properties.
   */
  private static final String USER_PROPERTIES_SYSTEM_PROPERTY =
      "org.castor.user.properties.location";

  /**
   * The <a href="http://jakarta.apache.org/commons/logging/">Jakarta Commons Logging</a> instance
   * used for all logging.
   */
  private static final Log LOG = LogFactory.getLog(AbstractProperties.class);

  /**
   * {@link ClassLoader} to be used for all classes of Castor and its required libraries.
   */
  private final ClassLoader _applicationClassLoader;

  /**
   * {@link ClassLoader} to be used for all domain objects that are marshalled/unmarshalled or
   * loaded from the database.
   */
  private final ClassLoader _domainClassLoader;

  /**
   * Parent properties.
   */
  private final AbstractProperties _parent;

  private final Map<Object, Object> _map = new HashMap<>();

  /**
   * Default constructor. Application and domain class loaders will be initialized to the one used
   * to load the concrete properties class. No parent properties will be set.
   */
  protected AbstractProperties() {
    this(null, null);
  }

  /**
   * Construct properties that uses the specified class loaders. No parent properties will be set.
   * 
   * @param app Classloader to be used for all classes of Castor and its required libraries.
   * @param domain Classloader to be used for all domain objects.
   */
  protected AbstractProperties(final ClassLoader app, final ClassLoader domain) {
    _applicationClassLoader = (app != null) ? app : getClass().getClassLoader();
    _domainClassLoader = (domain != null) ? domain : getClass().getClassLoader();

    _parent = null;
  }

  /**
   * Construct properties with given parent. Application and domain class loaders will be
   * initialized to the ones of the parent.
   * 
   * @param parent Parent properties.
   */
  protected AbstractProperties(final AbstractProperties parent) {
    _applicationClassLoader = parent.getApplicationClassLoader();
    _domainClassLoader = parent.getDomainClassLoader();

    _parent = parent;
  }

  /**
   * Get classloader to be used for all classes of Castor and its required libraries.
   * 
   * @return Classloader to be used for all classes of Castor and its required libraries.
   */
  public final ClassLoader getApplicationClassLoader() {
    return _applicationClassLoader;
  }

  /**
   * Get classloader to be used for all domain objects that are marshalled/unmarshalled or loaded
   * from the database.
   * 
   * @return Classloader to be used for all domain objects.
   */
  public final ClassLoader getDomainClassLoader() {
    return _domainClassLoader;
  }

  /**
   * Load module properties from default locations. <br/>
   * First it loads default properties contained in Castor JAR. This gets overwritten by a
   * properties found on Java library directory. If no properties could be found until that point a
   * PropertiesException will be thrown.
   * 
   * @param path Path to the default properties to load.
   * @param filename Name of the properties file.
   */
  protected void loadDefaultProperties(final String path, final String filename) {
    Properties properties = new Properties();

    // Get default properties from the Castor JAR.
    boolean inCastorJar = loadFromClassPath(properties, path + filename);

    // Get overriding properties from the Java library directory, ignore if not
    // found. If found merge existing properties.
    boolean inJavaLibDir = loadFromJavaHome(properties, filename);

    // Couldn't find properties in Castor jar nor Java library directory.
    if (!inCastorJar && !inJavaLibDir) {
      throw new PropertiesException("Failed to load properties: " + filename);
    }

    _map.putAll(properties);
  }

  /**
   * Load common user properties from classpath root and current working directory. <br/>
   * First it loads default properties contained in Castor JAR. This gets overwritten by properties
   * found on Java library directory. If no properties could be found until that point a
   * PropertiesException will be thrown. At last overriding properties are loaded from root of
   * classpath or, if that could not be found, from local working directory.
   * 
   * @param filename Name of the properties file.
   */
  protected void loadUserProperties(final String filename) {
    Properties properties = new Properties();

    // Get common properties from the classpath root, ignore if not found.
    boolean userPropertiesLoaded = loadFromClassPath(properties, "/" + filename);

    // If not found on classpath root, either it doesn't exist, or "." is not part of
    // the classpath, try looking at local working directory.
    if (!userPropertiesLoaded) {
      userPropertiesLoaded = loadFromWorkingDirectory(properties, filename);
    }

    if (!userPropertiesLoaded) {
      String property = System.getProperty(USER_PROPERTIES_SYSTEM_PROPERTY);
      if (property != null && property.length() > 0) {
        File file = new File(property);
        if (file.exists()) {
          LOG.info("Loading custom Castor properties from " + file.getAbsolutePath());
          userPropertiesLoaded = loadFromFile(properties, file);
        } else {
          LOG.warn(file.getAbsolutePath() + " is not a valid file.");
        }
      }
    }

    _map.putAll(properties);
  }

  /**
   * Load properties with given filename from classpath and merge them into the given properties.
   * 
   * @param properties Properties to merge the loaded ones into.
   * @param filename Name of the properties file to load from classpath.
   * @return <code>true</code> if properties could be loaded, <code>false</code> otherwise.
   */
  private boolean loadFromClassPath(final Properties properties, final String filename) {
    InputStream classPathStream = null;
    try {
      URL url = getClass().getResource(filename);
      if (url != null) {
        classPathStream = url.openStream();
        properties.load(classPathStream);

        if (LOG.isDebugEnabled()) {
          LOG.debug("Properties loaded from classpath: " + filename);
        }

        return true;
      }
      return false;
    } catch (Exception ex) {
      LOG.warn("Failed to load properties from classpath: " + filename, ex);
      return false;
    } finally {
      if (classPathStream != null) {
        try {
          classPathStream.close();
        } catch (IOException e) {
          LOG.warn("Failed to close properties from classpath: " + filename);
        }
      }
    }
  }

  /**
   * Load properties with given filename from Java library directory and merge them into the given
   * properties.
   * 
   * @param properties Properties to merge the loaded ones into.
   * @param filename Name of the properties file to load from Java library directory.
   * @return <code>true</code> if properties could be loaded, <code>false</code> otherwise.
   */
  private boolean loadFromJavaHome(final Properties properties, final String filename) {
    try {
      String javaHome = System.getProperty("java.home");
      if (javaHome == null) {
        return false;
      }
      return loadFromFile(properties, new File(new File(javaHome, "lib"), filename));
    } catch (SecurityException ex) {
      LOG.warn("Security policy prevented access to system property 'java.home'.", ex);
      return false;
    }
  }

  /**
   * Load properties with given filename from local working directory and merge them into the given
   * properties.
   * 
   * @param properties Properties to merge the loaded ones into.
   * @param filename Name of the properties file to load from local working directory.
   * @return <code>true</code> if properties could be loaded, <code>false</code> otherwise.
   */
  private boolean loadFromWorkingDirectory(final Properties properties, final String filename) {
    return loadFromFile(properties, new File(filename));
  }

  /**
   * Load properties with given file and merge them into the given properties.
   * 
   * @param properties Properties to merge the loaded ones into.
   * @param file Properties file to load.
   * @return <code>true</code> if properties could be loaded, <code>false</code> otherwise.
   */
  private boolean loadFromFile(final Properties properties, final File file) {
    try {
      if (file.exists() && file.canRead()) {
        try (InputStream fileStream = new FileInputStream(file)) {
          properties.load(fileStream);
        }

        if (LOG.isDebugEnabled()) {
          LOG.debug("Properties file loaded: " + file);
        }

        return true;
      }
      return false;
    } catch (SecurityException ex) {
      LOG.warn("Security policy prevented access to properties file: " + file, ex);
      return false;
    } catch (Exception ex) {
      LOG.warn("Failed to load properties file: " + file, ex);
      return false;
    }
  }

  /**
   * Put given value associated with given key into the properties map of this properties. If the
   * properties previously associated the key to another value the previous value will be returned.
   * If a mapping for the key previously exist in the parent properties only, the method returns
   * <code>null</code> and not the value of the parent. This allows to distingush if the mapping
   * existed in this properties or one of its parents. <br/>
   * Putting a value in this properties does not change the value of its parent but the parents
   * value isn't visible any more as it gets overwritten by this properties one. While this allows
   * to redefine the value of a property it isn't allowed to undefine it. Therefore a
   * <code>NullPointerException</code> will be thrown if the given value is <code>null</code>.
   * 
   * @param key Key of the property to put into properties.
   * @param value Value to put into properties associated with the given key..
   * @return Object in this properties that previously has been associated with the given key.
   */
  public final synchronized Object put(final String key, final Object value) {
    if (value == null) {
      throw new NullPointerException();
    }
    return _map.put(key, value);
  }

  /**
   * Remove any value previously associated with the given key from this properties. The value
   * previously associated with the key in this properties will be returned. If a mapping for the
   * key existed in the parent properties only, the method returns <code>null</code> and not the
   * value of the parent. This allows to distingush if the mapping existed in this properties or one
   * of its parents. <br/>
   * Removing the value from this properties does not mean that consecutive gets return
   * <code>null</code> as one of the parents may still contain a mapping for the key that was hidden
   * by the mapping in this properties.
   * 
   * @param key Key of the property to remove from properties.
   * @return Object in this properties that previously has been associated with the given key.
   */
  public final synchronized Object remove(final String key) {
    return _map.remove(key);
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to any object value, it will be returned as is. If the property is not found,
   * <code>null</code> will be returned.
   *
   * @param key Key of the property to get from properties.
   * @return Object in this property map with the specified key value.
   */
  protected synchronized Object get(final String key) {
    Object value = _map.get(key);
    if ((value == null) && (_parent != null)) {
      value = _parent.get(key);
    }
    return value;
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a boolean value, it will be returned as is. For string values that are
   * equal, ignore case, to 'true' or 'false', the respective boolean value will be returned. If the
   * property is not found, <code>null</code> will be returned. For all other types and string
   * values a PropertiesException will be thrown. This behaviour is intended for those usecases that
   * need distinguish between values that are missconfigured or not specified at all.
   *
   * @param key Property key.
   * @return Boolean value in this property map with the specified key value.
   */
  public final Boolean getBoolean(final String key) {
    Object objectValue = get(key);

    if (objectValue == null) {
      return null;
    } else if (objectValue instanceof Boolean) {
      return (Boolean) objectValue;
    } else if (objectValue instanceof String) {
      String stringValue = (String) objectValue;
      if ("true".equalsIgnoreCase(stringValue)) {
        return Boolean.TRUE;
      } else if ("false".equalsIgnoreCase(stringValue)) {
        return Boolean.FALSE;
      }
    }

    Object[] args = new Object[] {key, objectValue};
    String msg = "Properties value can not be converted to boolean: {0}={1}";
    throw new PropertiesException(MessageFormat.format(msg, args));
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a boolean value, it will be returned as is. For string values that are
   * equal, ignore case, to 'true' or 'false', the respective boolean value will be returned. In all
   * other cases the given default value will be returned.
   *
   * @param key Property key.
   * @param defaultValue Default value.
   * @return Boolean value in this property map with the specified key value.
   */
  public final boolean getBoolean(final String key, final boolean defaultValue) {
    Object objectValue = get(key);

    if (objectValue instanceof Boolean) {
      return ((Boolean) objectValue).booleanValue();
    } else if (objectValue instanceof String) {
      String stringValue = (String) objectValue;
      if ("true".equalsIgnoreCase(stringValue)) {
        return true;
      } else if ("false".equalsIgnoreCase(stringValue)) {
        return false;
      }
    }

    return defaultValue;
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a integer value, it will be returned as is. For string values that can be
   * interpreted as signed decimal integer, the respective integer value will be returned. If the
   * property is not found, <code>null</code> will be returned. For all other types and string
   * values a PropertiesException will be thrown. This behaviour is intended for those usecases that
   * need distinguish between values that are missconfigured or not specified at all.
   *
   * @param key Property key.
   * @return Integer value in this property map with the specified key value.
   */
  public final Integer getInteger(final String key) {
    Object objectValue = get(key);

    if (objectValue == null) {
      return null;
    } else if (objectValue instanceof Integer) {
      return (Integer) objectValue;
    } else if (objectValue instanceof String) {
      try {
        return Integer.valueOf((String) objectValue);
      } catch (NumberFormatException ex) {
        Object[] args = new Object[] {key, objectValue};
        String msg = "Properties value can not be converted to int: {0}={1}";
        throw new PropertiesException(MessageFormat.format(msg, args), ex);
      }
    }

    Object[] args = new Object[] {key, objectValue};
    String msg = "Properties value can not be converted to int: {0}={1}";
    throw new PropertiesException(MessageFormat.format(msg, args));
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a integer value, it will be returned as is. For string values that can be
   * interpreted as signed decimal integer, the respective integer value will be returned. In all
   * other cases the given default value will be returned.
   *
   * @param key Property key.
   * @param defaultValue Default value.
   * @return Integer value in this property map with the specified key value.
   */
  public final int getInteger(final String key, final int defaultValue) {
    Object objectValue = get(key);

    if (objectValue instanceof Integer) {
      return ((Integer) objectValue).intValue();
    } else if (objectValue instanceof String) {
      String stringValue = (String) objectValue;
      try {
        return Integer.parseInt(stringValue);
      } catch (NumberFormatException ex) {
        return defaultValue;
      }
    }

    return defaultValue;
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a string value, it will be returned as is. If the property is not found,
   * <code>null</code> will be returned. For all other types a PropertiesException will be thrown.
   *
   * @param key Property key.
   * @return String value in this property map with the specified key value.
   */
  public final String getString(final String key) {
    Object objectValue = get(key);

    if (objectValue == null) {
      return null;
    } else if (objectValue instanceof String) {
      return (String) objectValue;
    }

    Object[] args = new Object[] {key, objectValue};
    String msg = "Properties value is not a string: {0}={1}";
    throw new PropertiesException(MessageFormat.format(msg, args));
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a string value that is not empty, it will be returned as is. In all other
   * cases the given default value will be returned.
   *
   * @param key Property key.
   * @param defaultValue Default value.
   * @return String value in this property map with the specified key value.
   */
  public final String getString(final String key, final String defaultValue) {
    Object objectValue = get(key);

    if ((objectValue instanceof String) && !"".equals(objectValue)) {
      return (String) objectValue;
    }

    return defaultValue;
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a string array, it will be returned as is. A simple string will be converted
   * into a string array by splitting it into substrings at every occurence of ',' character. If the
   * property is not found, <code>null</code> will be returned. For all other types a
   * PropertiesException will be thrown.
   *
   * @param key Property key.
   * @return String array in this property map with the specified key value.
   */
  public final String[] getStringArray(final String key) {
    Object objectValue = get(key);

    if (objectValue == null) {
      return null;
    } else if (objectValue instanceof String[]) {
      return (String[]) objectValue;
    } else if (objectValue instanceof String) {
      return StringUtils.split((String) objectValue, ',');
    }

    Object[] args = new Object[] {key, objectValue};
    String msg = "Properties value is not a String[]: {0}={1}";
    throw new PropertiesException(MessageFormat.format(msg, args));
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a class, it will be returned as is. A simple string will be interpreted as
   * class name of which the class will be loaded with the given class loader. If the property is
   * not found, <code>null</code> will be returned. For all other types and if loading of the class
   * fails a PropertiesException will be thrown.
   *
   * @param key Property key.
   * @param loader Class loader to load classes with.
   * @return Class in this property map with the specified key value.
   */
  public final Class getClass(final String key, final ClassLoader loader) {
    Object objectValue = get(key);

    if (objectValue == null) {
      return null;
    } else if (objectValue instanceof Class) {
      return (Class) objectValue;
    } else if (objectValue instanceof String) {
      String classname = (String) objectValue;
      try {
        return loader.loadClass(classname);
      } catch (ClassNotFoundException ex) {
        Object[] args = new Object[] {key, classname};
        String msg = "Could not find class of properties value: {0}={1}";
        throw new PropertiesException(MessageFormat.format(msg, args), ex);
      }
    }

    Object[] args = new Object[] {key, objectValue};
    String msg = "Properties value is not a Class: {0}={1}";
    throw new PropertiesException(MessageFormat.format(msg, args));
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a class array, it will be returned as is. A simple string will be splitted
   * it into substrings at every occurence of ',' character. Each of these substrings will
   * interpreted as class name of which the class will be loaded with the given class loader. If the
   * property is not found, <code>null</code> will be returned. For all other types and if loading
   * of one of the classes fails a PropertiesException will be thrown.
   *
   * @param key Property key.
   * @param loader Class loader to load classes with.
   * @return Class array in this property map with the specified key value.
   */
  public final Class[] getClassArray(final String key, final ClassLoader loader) {
    Object objectValue = get(key);

    if (objectValue == null) {
      return null;
    } else if (objectValue instanceof Class[]) {
      return (Class[]) objectValue;
    } else if (objectValue instanceof String) {
      String[] classnames = StringUtils.split((String) objectValue, ',');
      Class[] classes = new Class[classnames.length];
      for (int i = 0; i < classnames.length; i++) {
        try {
          classes[i] = loader.loadClass(classnames[i]);
        } catch (ClassNotFoundException ex) {
          Object[] args = new Object[] {key, Integer.valueOf(i), classnames[i]};
          String msg = "Could not find class of properties value: {0}[{1}]={2}";
          throw new PropertiesException(MessageFormat.format(msg, args), ex);
        }
      }
      return classes;
    }

    Object[] args = new Object[] {key, objectValue};
    String msg = "Properties value is not a Class[]: {0}={1}";
    throw new PropertiesException(MessageFormat.format(msg, args));
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to any object value, it will be returned as is. If the property is not found,
   * <code>null</code> will be returned.
   *
   * @param key Property key.
   * @return Object in this property map with the specified key value.
   */
  public final Object getObject(final String key) {
    return get(key);
  }

  /**
   * Searches for the property with the specified key in this property map. If the key is not found
   * in this property map, the parent property map, and its parents, recursively, are then checked.
   * <br/>
   * If the key maps to a object array, it will be returned as is. A simple string will be splitted
   * it into substrings at every occurence of ',' character. Each of these substrings will
   * interpreted as class name of which the class will be loaded with the given class loader and
   * instantiated using its default constructor. If the property is not found, <code>null</code>
   * will be returned. For all other types and if loading or instantiation of one of the classes
   * fails a PropertiesException will be thrown.
   *
   * @param key Property key.
   * @param loader Class loader to load classes with.
   * @return Class array in this property map with the specified key value.
   */
  public final Object[] getObjectArray(final String key, final ClassLoader loader) {
    Object objectValue = get(key);

    if (objectValue == null) {
      return null;
    } else if (objectValue instanceof Object[]) {
      return (Object[]) objectValue;
    } else if (objectValue instanceof String) {
      List<Object> objects = new ArrayList<>();
      String[] classnames = StringUtils.split((String) objectValue, ',');
      for (int i = 0; i < classnames.length; i++) {
        String classname = classnames[i];
        try {
          if ((classname != null) && !"".equals(classname.trim())) {
            classname = classname.trim();
            objects.add(loader.loadClass(classname).newInstance());
          }
        } catch (ClassNotFoundException ex) {
          Object[] args = new Object[] {key, Integer.valueOf(i), classname};
          String msg = "Could not find configured class: {0}[{1}]={2}";
          throw new PropertiesException(MessageFormat.format(msg, args), ex);
        } catch (IllegalAccessException ex) {
          Object[] args = new Object[] {key, Integer.valueOf(i), classname};
          String msg = "Could not instantiate configured class: {0}[{1}]={2}";
          throw new PropertiesException(MessageFormat.format(msg, args), ex);
        } catch (InstantiationException ex) {
          Object[] args = new Object[] {key, Integer.valueOf(i), classname};
          String msg = "Could not instantiate configured class: {0}[{1}]={2}";
          throw new PropertiesException(MessageFormat.format(msg, args), ex);
        } catch (ExceptionInInitializerError ex) {
          Object[] args = new Object[] {key, Integer.valueOf(i), classname};
          String msg = "Could not instantiate configured class: {0}[{1}]={2}";
          throw new PropertiesException(MessageFormat.format(msg, args), ex);
        } catch (SecurityException ex) {
          Object[] args = new Object[] {key, Integer.valueOf(i), classname};
          String msg = "Could not instantiate configured class: {0}[{1}]={2}";
          throw new PropertiesException(MessageFormat.format(msg, args), ex);
        }
      }
      return objects.toArray();
    }

    Object[] args = new Object[] {key, objectValue};
    String msg = "Properties value is not an Object[]: {0}={1}";
    throw new PropertiesException(MessageFormat.format(msg, args));
  }

}
