/*
 * Copyright 2012 LinkedIn, Inc
 * 
 * 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.
 */

package azkaban.utils;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import org.apache.log4j.Logger;

/**
 * Hashmap implementation of a hierarchitical properties with helpful converter
 * functions and Exception throwing. This class is not threadsafe.
 */
public class Props {
	private final Map<String, String> _current;
	private Props _parent;
	private String source = null;

	/**
	 * Constructor for empty props with empty parent.
	 */
	public Props() {
		this(null);
	}

	/**
	 * Constructor for empty Props with parent override.
	 * 
	 * @param parent
	 */
	public Props(Props parent) {
		this._current = new HashMap<String, String>();
		this._parent = parent;
	}

	/**
	 * Load props from a file.
	 * 
	 * @param parent
	 * @param file
	 * @throws IOException
	 */
	public Props(Props parent, String filepath) throws IOException {
		this(parent, new File(filepath));
	}

	/**
	 * Load props from a file.
	 * 
	 * @param parent
	 * @param file
	 * @throws IOException
	 */
	public Props(Props parent, File file) throws IOException {
		this(parent);
		setSource(file.getPath());

		InputStream input = new BufferedInputStream(new FileInputStream(file));
		try {
			loadFrom(input);
		} catch (IOException e) {
			input.close();
			throw e;
		}
		input.close();
	}

	/**
	 * Create props from property input streams
	 * 
	 * @param parent
	 * @param inputStreams
	 * @throws IOException
	 */
	public Props(Props parent, InputStream inputStream) throws IOException {
		this(parent);
		loadFrom(inputStream);
	}

	/**
	 * 
	 * @param inputStream
	 * @throws IOException
	 */
	private void loadFrom(InputStream inputStream) throws IOException {
		Properties properties = new Properties();
		properties.load(inputStream);
		this.put(properties);
	}

	/**
	 * Create properties from maps of properties
	 * 
	 * @param parent
	 * @param props
	 */
	public Props(Props parent, Map<String, String>... props) {
		this(parent);
		for (int i = props.length - 1; i >= 0; i--) {
			this.putAll(props[i]);
		}
	}

	/**
	 * Create properties from Properties objects
	 * 
	 * @param parent
	 * @param properties
	 */
	public Props(Props parent, Properties... properties) {
		this(parent);
		for (int i = properties.length - 1; i >= 0; i--) {
			this.put(properties[i]);
		}
	}

	/**
	 * Create a Props object with the contents set to that of props.
	 * 
	 * @param parent
	 * @param props
	 */
	public Props(Props parent, Props props) {
		this(parent);
		if (props != null) {
			putAll(props);
		}
	}

	public Props getEarliestAncestor() {
		if (_parent == null) {
			return this;
		}
		
		return _parent.getEarliestAncestor();
	}
	
	/**
	 * Create a Props with a null parent from a list of key value pairing. i.e.
	 * [key1, value1, key2, value2 ...]
	 * 
	 * @param args
	 * @return
	 */
	public static Props of(String... args) {
		return of((Props) null, args);
	}

	/**
	 * Create a Props from a list of key value pairing. i.e. [key1, value1,
	 * key2, value2 ...]
	 * 
	 * @param args
	 * @return
	 */
	@SuppressWarnings("unchecked")
	public static Props of(Props parent, String... args) {
		if (args.length % 2 != 0) {
			throw new IllegalArgumentException(
					"Must have an equal number of keys and values.");
		}

		Map<String, String> vals = new HashMap<String, String>(args.length / 2);

		for (int i = 0; i < args.length; i += 2) {
			vals.put(args[i], args[i + 1]);
		}
		return new Props(parent, vals);
	}

	/**
	 * Clear the current Props, but leaves the parent untouched.
	 */
	public void clearLocal() {
		_current.clear();
	}

	/**
	 * Check key in current Props then search in parent
	 * 
	 * @param k
	 * @return
	 */
	public boolean containsKey(Object k) {
		return _current.containsKey(k)
				|| (_parent != null && _parent.containsKey(k));
	}

	/**
	 * Check value in current Props then search in parent
	 * 
	 * @param value
	 * @return
	 */
	public boolean containsValue(Object value) {
		return _current.containsValue(value)
				|| (_parent != null && _parent.containsValue(value));
	}

	/**
	 * Return value if available in current Props otherwise return from parent
	 * 
	 * @param key
	 * @return
	 */
	public String get(Object key) {
		if (_current.containsKey(key)) {
			return _current.get(key);
		} else if (_parent != null) {
			return _parent.get(key);
		} else {
			return null;
		}
	}

	/**
	 * Get the key set from the current Props
	 * 
	 * @return
	 */
	public Set<String> localKeySet() {
		return _current.keySet();
	}

	/**
	 * Get parent Props
	 * 
	 * @return
	 */
	public Props getParent() {
		return _parent;
	}

	/**
	 * Put the given string value for the string key. This method performs any
	 * variable substitution in the value replacing any occurance of ${name}
	 * with the value of get("name").
	 * 
	 * @param key
	 *            The key to put the value to
	 * @param value
	 *            The value to do substitution on and store
	 * 
	 * @throws IllegalArgumentException
	 *             If the variable given for substitution is not a valid key in
	 *             this Props.
	 */
	public String put(String key, String value) {
		return _current.put(key, value);
	}

	/**
	 * Put the given Properties into the Props. This method performs any
	 * variable substitution in the value replacing any occurrence of ${name}
	 * with the value of get("name"). get() is called first on the Props and
	 * next on the Properties object.
	 * 
	 * @param properties
	 *            The properties to put
	 * 
	 * @throws IllegalArgumentException
	 *             If the variable given for substitution is not a valid key in
	 *             this Props.
	 */
	public void put(Properties properties) {
		for (String propName : properties.stringPropertyNames()) {
			_current.put(propName, properties.getProperty(propName));
		}
	}

	/**
	 * Put integer
	 * 
	 * @param key
	 * @param value
	 * @return
	 */
	public String put(String key, Integer value) {
		return _current.put(key, value.toString());
	}

	/**
	 * Put Long. Stores as String.
	 * 
	 * @param key
	 * @param value
	 * @return
	 */
	public String put(String key, Long value) {
		return _current.put(key, value.toString());
	}

	/**
	 * Put Double. Stores as String.
	 * 
	 * @param key
	 * @param value
	 * @return
	 */
	public String put(String key, Double value) {
		return _current.put(key, value.toString());
	}

	/**
	 * Put everything in the map into the props.
	 * 
	 * @param m
	 */
	public void putAll(Map<? extends String, ? extends String> m) {
		if (m == null) {
			return;
		}

		for (Map.Entry<? extends String, ? extends String> entry : m.entrySet()) {
			this.put(entry.getKey(), entry.getValue());
		}
	}

	/**
	 * Put all properties in the props into the current props. Will handle null
	 * p.
	 * 
	 * @param p
	 */
	public void putAll(Props p) {
		if (p == null) {
			return;
		}

		for (String key : p.getKeySet()) {
			this.put(key, p.get(key));
		}
	}

	/**
	 * Puts only the local props from p into the current properties
	 * 
	 * @param p
	 */
	public void putLocal(Props p) {
		for (String key : p.localKeySet()) {
			this.put(key, p.get(key));
		}
	}

	/**
	 * Remove only the local value of key s, and not the parents.
	 * 
	 * @param s
	 * @return
	 */
	public String removeLocal(Object s) {
		return _current.remove(s);
	}

	/**
	 * The number of unique keys defined by this Props and all parent Props
	 */
	public int size() {
		return getKeySet().size();
	}

	/**
	 * The number of unique keys defined by this Props (keys defined only in
	 * parent Props are not counted)
	 */
	public int localSize() {
		return _current.size();
	}

	/**
	 * Attempts to return the Class that corresponds to the Props value. If the
	 * class doesn't exit, an IllegalArgumentException will be thrown.
	 * 
	 * @param key
	 * @return
	 */
	public Class<?> getClass(String key) {
		try {
			if (containsKey(key)) {
				return Class.forName(get(key));
			} else {
				throw new UndefinedPropertyException(
						"Missing required property '" + key + "'");
			}
		} catch (ClassNotFoundException e) {
			throw new IllegalArgumentException(e);
		}
	}

	public Class<?> getClass(String key, boolean initialize, ClassLoader cl) {
		try {
			if (containsKey(key)) {
				return Class.forName(get(key), initialize, cl);
			} else {
				throw new UndefinedPropertyException(
						"Missing required property '" + key + "'");
			}
		} catch (ClassNotFoundException e) {
			throw new IllegalArgumentException(e);
		}
	}
	
	/**
	 * Gets the class from the Props. If it doesn't exist, it will return the
	 * defaultClass
	 * 
	 * @param key
	 * @param c
	 * @return
	 */
	public Class<?> getClass(String key, Class<?> defaultClass) {
		if (containsKey(key)) {
			return getClass(key);
		} else {
			return defaultClass;
		}
	}

	/**
	 * Gets the string from the Props. If it doesn't exist, it will return the
	 * defaultValue
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public String getString(String key, String defaultValue) {
		if (containsKey(key)) {
			return get(key);
		} else {
			return defaultValue;
		}
	}

	/**
	 * Gets the string from the Props. If it doesn't exist, throw and
	 * UndefinedPropertiesException
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public String getString(String key) {
		if (containsKey(key)) {
			return get(key);
		} else {
			throw new UndefinedPropertyException("Missing required property '"
					+ key + "'");
		}
	}

	/**
	 * Returns a list of strings with the comma as the separator of the value
	 * 
	 * @param key
	 * @return
	 */
	public List<String> getStringList(String key) {
		return getStringList(key, "\\s*,\\s*");
	}

	/**
	 * Returns a list of strings with the sep as the separator of the value
	 * 
	 * @param key
	 * @param sep
	 * @return
	 */
	public List<String> getStringList(String key, String sep) {
		String val = get(key);
		if (val == null || val.trim().length() == 0) {
			return Collections.emptyList();
		}

		if (containsKey(key)) {
			return Arrays.asList(val.split(sep));
		} else {
			throw new UndefinedPropertyException("Missing required property '"
					+ key + "'");
		}
	}

	/**
	 * Returns a list of strings with the comma as the separator of the value.
	 * If the value is null, it'll return the defaultValue.
	 * 
	 * @param key
	 * @return
	 */
	public List<String> getStringList(String key, List<String> defaultValue) {
		if (containsKey(key)) {
			return getStringList(key);
		} else {
			return defaultValue;
		}
	}

	/**
	 * Returns a list of strings with the sep as the separator of the value. If
	 * the value is null, it'll return the defaultValue.
	 * 
	 * @param key
	 * @return
	 */
	public List<String> getStringList(String key, List<String> defaultValue,
			String sep) {
		if (containsKey(key)) {
			return getStringList(key, sep);
		} else {
			return defaultValue;
		}
	}

	/**
	 * Returns true if the value equals "true". If the value is null, then the
	 * default value is returned.
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public boolean getBoolean(String key, boolean defaultValue) {
		if (containsKey(key)) {
			return "true".equalsIgnoreCase(get(key).trim());
		} else {
			return defaultValue;
		}
	}

	/**
	 * Returns true if the value equals "true". If the value is null, then an
	 * UndefinedPropertyException is thrown.
	 * 
	 * @param key
	 * @return
	 */
	public boolean getBoolean(String key) {
		if (containsKey(key))
			return "true".equalsIgnoreCase(get(key));
		else
			throw new UndefinedPropertyException("Missing required property '"
					+ key + "'");
	}

	/**
	 * Returns the long representation of the value. If the value is null, then
	 * the default value is returned. If the value isn't a long, then a parse
	 * exception will be thrown.
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public long getLong(String name, long defaultValue) {
		if (containsKey(name)) {
			return Long.parseLong(get(name));
		} else {
			return defaultValue;
		}
	}

	/**
	 * Returns the long representation of the value. If the value is null, then
	 * a UndefinedPropertyException will be thrown. If the value isn't a long,
	 * then a parse exception will be thrown.
	 * 
	 * @param key
	 * @return
	 */
	public long getLong(String name) {
		if (containsKey(name)) {
			return Long.parseLong(get(name));
		} else {
			throw new UndefinedPropertyException("Missing required property '"
					+ name + "'");
		}
	}

	/**
	 * Returns the int representation of the value. If the value is null, then
	 * the default value is returned. If the value isn't a int, then a parse
	 * exception will be thrown.
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public int getInt(String name, int defaultValue) {
		if (containsKey(name)) {
			return Integer.parseInt(get(name).trim());
		} else {
			return defaultValue;
		}
	}

	/**
	 * Returns the int representation of the value. If the value is null, then a
	 * UndefinedPropertyException will be thrown. If the value isn't a int, then
	 * a parse exception will be thrown.
	 * 
	 * @param key
	 * @return
	 */
	public int getInt(String name) {
		if (containsKey(name)) {
			return Integer.parseInt(get(name).trim());
		} else {
			throw new UndefinedPropertyException("Missing required property '"
					+ name + "'");
		}
	}

	/**
	 * Returns the double representation of the value. If the value is null,
	 * then the default value is returned. If the value isn't a double, then a
	 * parse exception will be thrown.
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public double getDouble(String name, double defaultValue) {
		if (containsKey(name)) {
			return Double.parseDouble(get(name).trim());
		} else {
			return defaultValue;
		}
	}

	/**
	 * Returns the double representation of the value. If the value is null,
	 * then a UndefinedPropertyException will be thrown. If the value isn't a
	 * double, then a parse exception will be thrown.
	 * 
	 * @param key
	 * @return
	 */
	public double getDouble(String name) {
		if (containsKey(name)) {
			return Double.parseDouble(get(name).trim());
		} else {
			throw new UndefinedPropertyException("Missing required property '"
					+ name + "'");
		}
	}

	/**
	 * Returns the uri representation of the value. If the value is null, then
	 * the default value is returned. If the value isn't a uri, then a
	 * IllegalArgumentException will be thrown.
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public URI getUri(String name) {
		if (containsKey(name)) {
			try {
				return new URI(get(name));
			} catch (URISyntaxException e) {
				throw new IllegalArgumentException(e.getMessage());
			}
		} else {
			throw new UndefinedPropertyException("Missing required property '"
					+ name + "'");
		}
	}

	/**
	 * Returns the double representation of the value. If the value is null,
	 * then the default value is returned. If the value isn't a uri, then a
	 * IllegalArgumentException will be thrown.
	 * 
	 * @param key
	 * @param defaultValue
	 * @return
	 */
	public URI getUri(String name, URI defaultValue) {
		if (containsKey(name)) {
			return getUri(name);
		} else {
			return defaultValue;
		}
	}

	public URI getUri(String name, String defaultValue) {
		try {
			return getUri(name, new URI(defaultValue));
		} catch (URISyntaxException e) {
			throw new IllegalArgumentException(e.getMessage());
		}
	}

	/**
	 * Store only those properties defined at this local level
	 * 
	 * @param file
	 *            The file to write to
	 * @throws IOException
	 *             If the file can't be found or there is an io error
	 */
	public void storeLocal(File file) throws IOException {
		BufferedOutputStream out = new BufferedOutputStream(
				new FileOutputStream(file));
		try {
			storeLocal(out);
		} finally {
			out.close();
		}
	}

	/**
	 * Returns a copy of only the local values of this props
	 * 
	 * @return
	 */
	@SuppressWarnings("unchecked")
	public Props local() {
		return new Props(null, _current);
	}

	/**
	 * Store only those properties defined at this local level
	 * 
	 * @param out
	 *            The output stream to write to
	 * @throws IOException
	 *             If the file can't be found or there is an io error
	 */
	public void storeLocal(OutputStream out) throws IOException {
		Properties p = new Properties();
		for (String key : _current.keySet()) {
			p.setProperty(key, get(key));
		}
		p.store(out, null);
	}

	/**
	 * Returns a java.util.Properties file populated with the stuff in here.
	 * 
	 * @return
	 */
	public Properties toProperties() {
		Properties p = new Properties();
		for (String key : _current.keySet()) {
			p.setProperty(key, get(key));
		}

		return p;
	}

	/**
	 * Store all properties, those local and also those in parent props
	 * 
	 * @param file
	 *            The file to store to
	 * @throws IOException
	 *             If there is an error writing
	 */
	public void storeFlattened(File file) throws IOException {
		BufferedOutputStream out = new BufferedOutputStream(
				new FileOutputStream(file));
		try {
			storeFlattened(out);
		} finally {
			out.close();
		}
	}

	/**
	 * Store all properties, those local and also those in parent props
	 * 
	 * @param out
	 *            The stream to write to
	 * @throws IOException
	 *             If there is an error writing
	 */
	public void storeFlattened(OutputStream out) throws IOException {
		Properties p = new Properties();
		for (Props curr = this; curr != null; curr = curr.getParent()) {
			for (String key : curr.localKeySet()) {
				if (!p.containsKey(key)) {
					p.setProperty(key, get(key));
				}
			}
		}

		p.store(out, null);
	}

	/**
	 * Get a map of all properties by string prefix
	 * 
	 * @param prefix
	 *            The string prefix
	 */
	public Map<String, String> getMapByPrefix(String prefix) {
		Map<String, String> values = new HashMap<String, String>();

		if (_parent != null) {
			for (Map.Entry<String, String> entry : _parent.getMapByPrefix(
					prefix).entrySet()) {
				values.put(entry.getKey(), entry.getValue());
			}
		}

		for (String key : this.localKeySet()) {
			if (key.startsWith(prefix)) {
				values.put(key.substring(prefix.length()), get(key));
			}
		}
		return values;
	}

	/**
	 * Returns a set of all keys, including the parents
	 * 
	 * @return
	 */
	public Set<String> getKeySet() {
		HashSet<String> keySet = new HashSet<String>();

		keySet.addAll(localKeySet());

		if (_parent != null) {
			keySet.addAll(_parent.getKeySet());
		}

		return keySet;
	}

	/**
	 * Logs the property in the given logger
	 * 
	 * @param logger
	 * @param comment
	 */
	public void logProperties(Logger logger, String comment) {
		logger.info(comment);

		for (String key : getKeySet()) {
			logger.info("  key=" + key + " value=" + get(key));
		}
	}

	/**
	 * Clones the Props p object and all of its parents.
	 * 
	 * @param p
	 * @return
	 */
	public static Props clone(Props p) {
		return copyNext(p);
	}

	/**
	 * 
	 * @param source
	 * @return
	 */
	private static Props copyNext(Props source) {
		Props priorNodeCopy = null;
		if (source.getParent() != null) {
			priorNodeCopy = copyNext(source.getParent());
		}
		Props dest = new Props(priorNodeCopy);
		for (String key : source.localKeySet()) {
			dest.put(key, source.get(key));
		}

		return dest;
	}

	/**
     */
	@Override
	public boolean equals(Object o) {
		if (o == this) {
			return true;
		} else if (o == null) {
			return false;
		} else if (o.getClass() != Props.class) {
			return false;
		}

		Props p = (Props) o;
		return _current.equals(p._current)
				&& Utils.equals(this._parent, p._parent);
	}

	/**
	 * Returns true if the properties are equivalent, regardless of the
	 * hierarchy.
	 * 
	 * @param p
	 * @return
	 */
	public boolean equalsProps(Props p) {
		if (p == null) {
			return false;
		}

		final Set<String> myKeySet = getKeySet();
		for (String s : myKeySet) {
			if (!get(s).equals(p.get(s))) {
				return false;
			}
		}

		return myKeySet.size() == p.getKeySet().size();
	}

	/**
     * 
     */
	@Override
	public int hashCode() {
		int code = this._current.hashCode();
		if (_parent != null)
			code += _parent.hashCode();
		return code;
	}

	/**
     * 
     */
	@Override
	public String toString() {
		StringBuilder builder = new StringBuilder("{");
		for (Map.Entry<String, String> entry : this._current.entrySet()) {
			builder.append(entry.getKey());
			builder.append(": ");
			builder.append(entry.getValue());
			builder.append(", ");
		}
		if (_parent != null) {
			builder.append(" parent = ");
			builder.append(_parent.toString());
		}
		builder.append("}");
		return builder.toString();
	}

	public String getSource() {
		return source;
	}

	public void setSource(String source) {
		this.source = source;
	}

	public void setParent(Props prop) {
		this._parent = prop;
	}
}
