/*
 * Copyright (C) 2009-2013 The Project Lombok Authors.
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package lombok.core;

import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import lombok.core.AST.Kind;

/**
 * Represents a single annotation in a source file and can be used to query the parameters present on it.
 * 
 * @param A The annotation that this class represents, such as {@code lombok.Getter}
 */
public class AnnotationValues<A extends Annotation> {
	private final Class<A> type;
	private final Map<String, AnnotationValue> values;
	private final LombokNode<?, ?, ?> ast;
	
	/**
	 * Represents a single method on the annotation class. For example, the value() method on the Getter annotation.
	 */
	public static class AnnotationValue {
		/** A list of the raw expressions. List is size 1 unless an array is provided. */
		public final List<String> raws;
		
		/** Guesses for each raw expression. If the raw expression is a literal expression, the guess will
		 * likely be right. If not, it'll be wrong. */
		public final List<Object> valueGuesses;
		
		/** A list of the actual expressions. List is size 1 unless an array is provided. */
		public final List<Object> expressions;
		
		private final LombokNode<?, ?, ?> node;
		private final boolean isExplicit;
		
		/**
		 * Like the other constructor, but used for when the annotation method is initialized with an array value.
		 */
		public AnnotationValue(LombokNode<?, ?, ?> node, List<String> raws, List<Object> expressions, List<Object> valueGuesses, boolean isExplicit) {
			this.node = node;
			this.raws = raws;
			this.expressions = expressions;
			this.valueGuesses = valueGuesses;
			this.isExplicit = isExplicit;
		}
		
		/**
		 *  Override this if you want more specific behaviour (to get the source position just right).
		 * 
		 * @param message English message with the problem.
		 * @param valueIdx The index into the values for this annotation key that caused the problem.
		 *   -1 for a problem that applies to all values, otherwise the 0-based index into an array of values.
		 *   If there is no array for this value (e.g. value=1 instead of value={1,2}), then always -1 or 0.
		 */
		public void setError(String message, int valueIdx) {
			node.addError(message);
		}
		
		/**
		 *  Override this if you want more specific behaviour (to get the source position just right).
		 * 
		 * @param message English message with the problem.
		 * @param valueIdx The index into the values for this annotation key that caused the problem.
		 *   -1 for a problem that applies to all values, otherwise the 0-based index into an array of values.
		 *   If there is no array for this value (e.g. value=1 instead of value={1,2}), then always -1 or 0.
		 */
		public void setWarning(String message, int valueIdx) {
			node.addError(message);
		}
		
		/** {@inheritDoc} */
		@Override public String toString() {
			return "raws: " + raws + " valueGuesses: " + valueGuesses;
		}
		
		public boolean isExplicit() {
			return isExplicit;
		}
	}
	
	/**
	 * Creates a new AnnotationValues.
	 * 
	 * @param type The annotation type. For example, "Getter.class"
	 * @param values a Map of method names to AnnotationValue instances, for example 'value -> annotationValue instance'.
	 * @param ast The Annotation node.
	 */
	public AnnotationValues(Class<A> type, Map<String, AnnotationValue> values, LombokNode<?, ?, ?> ast) {
		this.type = type;
		this.values = values;
		this.ast = ast;
	}
	
	public static <A extends Annotation> AnnotationValues<A> of(Class<A> type) {
		return new AnnotationValues<A>(type, Collections.<String, AnnotationValue>emptyMap(), null);
	}
	
	/**
	 * Creates a new annotation wrapper with all default values, and using the provided ast as lookup anchor for
	 * class literals.
	 */
	public static <A extends Annotation> AnnotationValues<A> of(Class<A> type, LombokNode<?, ?, ?> ast) {
		return new AnnotationValues<A>(type, Collections.<String, AnnotationValue>emptyMap(), ast);
	}
	
	/**
	 * Thrown on the fly if an actual annotation instance procured via the {@link #getInstance()} method is queried
	 * for a method for which this AnnotationValues instance either doesn't have a guess or can't manage to fit
	 * the guess into the required data type.
	 */
	public static class AnnotationValueDecodeFail extends RuntimeException {
		private static final long serialVersionUID = 1L;
		
		/** The index into an array initializer (e.g. if the second value in an array initializer is
		 * an integer constant expression like '5+SomeOtherClass.CONSTANT', this exception will be thrown,
		 * and you'll get a '1' for idx. */
		public final int idx;
		
		/** The AnnotationValue object that goes with the annotation method for which the failure occurred. */
		public final AnnotationValue owner;
		
		public AnnotationValueDecodeFail(AnnotationValue owner, String msg, int idx) {
			super(msg);
			this.idx = idx;
			this.owner = owner;
		}
	}
	
	private static AnnotationValueDecodeFail makeNoDefaultFail(AnnotationValue owner, Method method) {
		return new AnnotationValueDecodeFail(owner, 
				"No value supplied but " + method.getName() + " has no default either.", -1);
	}
	
	private A cachedInstance = null;
	
	/**
	 * Creates an actual annotation instance. You can use this to query any annotation methods, except for
	 * those annotation methods with class literals, as those can most likely not be turned into Class objects.
	 * 
	 * If some of the methods cannot be implemented, this method still works; it's only when you call a method
	 * that has a problematic value that an AnnotationValueDecodeFail exception occurs.
	 */
	@SuppressWarnings("unchecked")
	public A getInstance() {
		if (cachedInstance != null) return cachedInstance;
		InvocationHandler invocations = new InvocationHandler() {
			public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
				AnnotationValue v = values.get(method.getName());
				if (v == null) {
					Object defaultValue = method.getDefaultValue();
					if (defaultValue != null) return defaultValue;
					throw makeNoDefaultFail(v, method);
				}
				
				boolean isArray = false;
				Class<?> expected = method.getReturnType();
				Object array = null;
				if (expected.isArray()) {
					isArray = true;
					expected = expected.getComponentType();
					array = Array.newInstance(expected, v.valueGuesses.size());
				}
				
				if (!isArray && v.valueGuesses.size() > 1) {
					throw new AnnotationValueDecodeFail(v, 
							"Expected a single value, but " + method.getName() + " has an array of values", -1);
				}
				
				if (v.valueGuesses.size() == 0 && !isArray) {
					Object defaultValue = method.getDefaultValue();
					if (defaultValue == null) throw makeNoDefaultFail(v, method);
					return defaultValue;
				}
				
				int idx = 0;
				for (Object guess : v.valueGuesses) {
					Object result = guess == null ? null : guessToType(guess, expected, v, idx);
					if (!isArray) {
						if (result == null) {
							Object defaultValue = method.getDefaultValue();
							if (defaultValue == null) throw makeNoDefaultFail(v, method);
							return defaultValue;
						}
						return result;
					} 
					if (result == null) {
						if (v.valueGuesses.size() == 1) {
							Object defaultValue = method.getDefaultValue();
							if (defaultValue == null) throw makeNoDefaultFail(v, method);
							return defaultValue;
						} 
						throw new AnnotationValueDecodeFail(v, 
								"I can't make sense of this annotation value. Try using a fully qualified literal.", idx);
					}
					Array.set(array, idx++, result);
				}
				
				return array;
			}
		};
		
		return cachedInstance = (A) Proxy.newProxyInstance(type.getClassLoader(), new Class[] { type }, invocations);
	}
	
	private Object guessToType(Object guess, Class<?> expected, AnnotationValue v, int pos) {
		if (expected == int.class) {
			if (guess instanceof Integer || guess instanceof Short || guess instanceof Byte) {
				return ((Number)guess).intValue();
			}
		}
		
		if (expected == long.class) {
			if (guess instanceof Long || guess instanceof Integer || guess instanceof Short || guess instanceof Byte) {
				return ((Number)guess).longValue();
			}
		}
		
		if (expected == short.class) {
			if (guess instanceof Integer || guess instanceof Short || guess instanceof Byte) {
				int intVal = ((Number)guess).intValue();
				int shortVal = ((Number)guess).shortValue();
				if (shortVal == intVal) return shortVal;
			}
		}
		
		if (expected == byte.class) {
			if (guess instanceof Integer || guess instanceof Short || guess instanceof Byte) {
				int intVal = ((Number)guess).intValue();
				int byteVal = ((Number)guess).byteValue();
				if (byteVal == intVal) return byteVal;
			}
		}
		
		if (expected == double.class) {
			if (guess instanceof Number) return ((Number)guess).doubleValue();
		}
		
		if (expected == float.class) {
			if (guess instanceof Number) return ((Number)guess).floatValue();
		}
		
		if (expected == boolean.class) {
			if (guess instanceof Boolean) return ((Boolean)guess).booleanValue();
		}
		
		if (expected == char.class) {
			if (guess instanceof Character) return ((Character)guess).charValue();
		}
		
		if (expected == String.class) {
			if (guess instanceof String) return guess;
		}
		
		if (Enum.class.isAssignableFrom(expected) ) {
			if (guess instanceof String) {
				for (Object enumConstant : expected.getEnumConstants()) {
					String target = ((Enum<?>)enumConstant).name();
					if (target.equals(guess)) return enumConstant;
				}
				throw new AnnotationValueDecodeFail(v,
						"Can't translate " + guess + " to an enum of type " + expected, pos);
			}
		}
		
		if (Class.class == expected) {
			if (guess instanceof String) try {
				return Class.forName(toFQ((String)guess));
			} catch (ClassNotFoundException e) {
				throw new AnnotationValueDecodeFail(v,
						"Can't translate " + guess + " to a class object.", pos);
			}
		}
		
		throw new AnnotationValueDecodeFail(v,
				"Can't translate a " + guess.getClass() + " to the expected " + expected, pos);
	}
	
	/**
	 * Returns the raw expressions used for the provided {@code annotationMethodName}.
	 * 
	 * You should use this method for annotation methods that return {@code Class} objects. Remember that
	 * class literals end in ".class" which you probably want to strip off.
	 */
	public List<String> getRawExpressions(String annotationMethodName) {
		AnnotationValue v = values.get(annotationMethodName);
		return v == null ? Collections.<String>emptyList() : v.raws;
	}
	
	/**
	 * Returns the actual expressions used for the provided {@code annotationMethodName}.
	 */
	public List<Object> getActualExpressions(String annotationMethodName) {
		AnnotationValue v = values.get(annotationMethodName);
		return v == null ? Collections.<Object>emptyList() : v.expressions;
	}
	
	public boolean isExplicit(String annotationMethodName) {
		AnnotationValue annotationValue = values.get(annotationMethodName);
		return annotationValue != null && annotationValue.isExplicit();
	}
	
	/**
	 * Convenience method to return the first result in a {@link #getRawExpressions(String)} call.
	 * 
	 * You should use this method if the annotation method is not an array type.
	 */
	public String getRawExpression(String annotationMethodName) {
		List<String> l = getRawExpressions(annotationMethodName);
		return l.isEmpty() ? null : l.get(0);
	}
	
	/**
	 * Convenience method to return the first result in a {@link #getActualExpressions(String)} call.
	 * 
	 * You should use this method if the annotation method is not an array type.
	 */
	public Object getActualExpression(String annotationMethodName) {
		List<Object> l = getActualExpressions(annotationMethodName);
		return l.isEmpty() ? null : l.get(0);
	}
	
	/** Generates an error message on the stated annotation value (you should only call this method if you know it's there!) */
	public void setError(String annotationMethodName, String message) {
		setError(annotationMethodName, message, -1);
	}
	
	/** Generates a warning message on the stated annotation value (you should only call this method if you know it's there!) */
	public void setWarning(String annotationMethodName, String message) {
		setWarning(annotationMethodName, message, -1);
	}
	
	/** Generates an error message on the stated annotation value, which must have an array initializer.
	 * The index-th item in the initializer will carry the error (you should only call this method if you know it's there!) */
	public void setError(String annotationMethodName, String message, int index) {
		AnnotationValue v = values.get(annotationMethodName);
		if (v == null) return;
		v.setError(message, index);
	}
	
	/** Generates a warning message on the stated annotation value, which must have an array initializer.
	 * The index-th item in the initializer will carry the error (you should only call this method if you know it's there!) */
	public void setWarning(String annotationMethodName, String message, int index) {
		AnnotationValue v = values.get(annotationMethodName);
		if (v == null) return;
		v.setWarning(message, index);
	}
	
	/**
	 * Attempts to translate class literals to their fully qualified names, such as 'Throwable.class' to 'java.lang.Throwable'.
	 * 
	 * This process is at best a guess, but it will take into account import statements.
	 */
	public List<String> getProbableFQTypes(String annotationMethodName) {
		List<String> result = new ArrayList<String>();
		AnnotationValue v = values.get(annotationMethodName);
		if (v == null) return Collections.emptyList();
		
		for (Object o : v.valueGuesses) result.add(o == null ? null : toFQ(o.toString()));
		return result;
	}
	
	/**
	 * Convenience method to return the first result in a {@link #getProbableFQType(String)} call.
	 * 
	 * You should use this method if the annotation method is not an array type.
	 */
	public String getProbableFQType(String annotationMethodName) {
		List<String> l = getProbableFQTypes(annotationMethodName);
		return l.isEmpty() ? null : l.get(0);
	}
	
	/*
	 * Credit goes to Petr Jiricka of Sun for highlighting the problems with the earlier version of this method.
	 */
	private String toFQ(String typeName) {
		String prefix = typeName.indexOf('.') > -1 ? typeName.substring(0, typeName.indexOf('.')) : typeName;
		
		/* 1. Walk through type names in this source file at this level. */ {
			LombokNode<?, ?, ?> n = ast;
			walkThroughCU:
			while (n != null) {
				if (n.getKind() == Kind.TYPE) {
					String simpleName = n.getName();
					if (prefix.equals(simpleName)) {
						//We found a matching type name in the local hierarchy!
						List<String> outerNames = new ArrayList<String>();
						while (true) {
							n = n.up();
							if (n == null || n.getKind() == Kind.COMPILATION_UNIT) break;
							if (n.getKind() == Kind.TYPE) outerNames.add(n.getName());
							//If our type has a parent that isn't either the CompilationUnit or another type, then we are
							//a method-local class or an anonymous inner class literal. These technically do have FQNs
							//and we may, with a lot of effort, figure out their name, but, that's some fairly horrible code
							//style and these methods have 'probable' in their name for a reason.
							break walkThroughCU;
						}
						StringBuilder result = new StringBuilder();
						if (ast.getPackageDeclaration() != null) result.append(ast.getPackageDeclaration());
						if (result.length() > 0) result.append('.');
						Collections.reverse(outerNames);
						for (String outerName : outerNames) result.append(outerName).append('.');
						result.append(typeName);
						return result.toString();
					}
				}
				n = n.up();
			}
		}
		
		/* 2. Walk through non-star imports and search for a match. */ {
			if (prefix.equals(typeName)) {
				String fqn = ast.getImportList().getFullyQualifiedNameForSimpleName(typeName);
				if (fqn != null) return fqn;
			}
		}
		
		/* 3. Walk through star imports and, if they start with "java.", use Class.forName based resolution. */ {
			for (String potential : ast.getImportList().applyNameToStarImports("java", typeName)) {
				try {
					Class<?> c = Class.forName(potential);
					if (c != null) return c.getName();
				} catch (Throwable t) {
					//Class.forName failed for whatever reason - it most likely does not exist, continue.
				}
			}
		}
		
		/* 4. If the type name is a simple name, then our last guess is that it's another class in this package. */ {
			if (typeName.indexOf('.') == -1) return inLocalPackage(ast, typeName);
		}
		
		/* 5. It's either an FQN or a nested class in another class in our package. Use code conventions to guess. */ {
			char firstChar = typeName.charAt(0);
			if (Character.isTitleCase(firstChar) || Character.isUpperCase(firstChar)) {
				//Class names start with uppercase letters, so presume it's a nested class in another class in our package.
				return inLocalPackage(ast, typeName);
			}
			
			//Presume it's fully qualified.
			return typeName;
		}
	}
	
	private static String inLocalPackage(LombokNode<?, ?, ?> node, String typeName) {
		StringBuilder result = new StringBuilder();
		if (node != null && node.getPackageDeclaration() != null) result.append(node.getPackageDeclaration());
		if (result.length() > 0) result.append('.');
		result.append(typeName);
		return result.toString();
	}
}
