package com.j256.simplejmx.server;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.management.Attribute;
import javax.management.AttributeList;
import javax.management.AttributeNotFoundException;
import javax.management.DynamicMBean;
import javax.management.MBeanAttributeInfo;
import javax.management.MBeanException;
import javax.management.MBeanInfo;
import javax.management.MBeanOperationInfo;
import javax.management.MBeanParameterInfo;
import javax.management.ReflectionException;

import com.j256.simplejmx.common.JmxAttributeField;
import com.j256.simplejmx.common.JmxAttributeFieldInfo;
import com.j256.simplejmx.common.JmxAttributeMethod;
import com.j256.simplejmx.common.JmxAttributeMethodInfo;
import com.j256.simplejmx.common.JmxOperation;
import com.j256.simplejmx.common.JmxOperationInfo;
import com.j256.simplejmx.common.JmxResource;

/**
 * This wraps an object that has been registered in the server using {@link JmxServer#register(Object)}. We wrap the
 * object so we can expose its attributes and operations using annotations and reflection. This handles the JMX server
 * calls to attributes and operations by calling through the delegation object.
 * 
 * @author graywatson
 */
public class ReflectionMbean implements DynamicMBean {

	private final Object target;
	private final String description;
	private final Map<String, AttributeMethodInfo> attributeMethodMap = new HashMap<String, AttributeMethodInfo>();
	private final Map<NameParams, Method> operationMethodMap = new HashMap<NameParams, Method>();
	private final Map<String, AttributeFieldInfo> attributeFieldMap = new HashMap<String, AttributeFieldInfo>();
	private final MBeanInfo mbeanInfo;

	/**
	 * Create a mbean associated with a target object that must have a {@link JmxResource} annotation.
	 */
	public ReflectionMbean(Object target, String description) {
		this(target, description, null, null, null, false);
	}

	/**
	 * Create a mbean associated with a wrapped object that exposes all public fields and methods.
	 */
	public ReflectionMbean(PublishAllBeanWrapper wrapper) {
		this(wrapper.getTarget(), null, wrapper.getAttributeFieldInfos(), wrapper.getAttributeMethodInfos(),
				wrapper.getOperationInfos(), true);
	}

	/**
	 * Create a mbean associated with a target object with user provided attribute and operation information.
	 */
	public ReflectionMbean(Object target, String description, JmxAttributeFieldInfo[] attributeFieldInfos,
			JmxAttributeMethodInfo[] attributeMethodInfos, JmxOperationInfo[] operationInfos, boolean ignoreErrors) {
		this.target = target;
		this.description = preprocessDescription(target, description);
		this.mbeanInfo = buildMbeanInfo(attributeFieldInfos, attributeMethodInfos, operationInfos, ignoreErrors);
	}

	@Override
	public MBeanInfo getMBeanInfo() {
		return mbeanInfo;
	}

	@Override
	public Object getAttribute(String attributeName) throws AttributeNotFoundException, ReflectionException {
		AttributeMethodInfo methodInfo = attributeMethodMap.get(attributeName);
		if (methodInfo == null) {
			AttributeFieldInfo fieldInfo = attributeFieldMap.get(attributeName);
			if (fieldInfo == null || !fieldInfo.isGetter) {
				throwUnknownAttributeException(attributeName);
			}
			try {
				// get the value by using reflection on the Field
				return fieldInfo.field.get(target);
			} catch (Exception e) {
				throw new ReflectionException(e, "Invoking getter attribute on field " + fieldInfo.field.getName()
						+ " on " + target.getClass() + " threw exception");
			}
		} else {
			if (methodInfo.getterMethod == null) {
				throwUnknownAttributeException(attributeName);
			}
			try {
				// get the value by calling the method
				return methodInfo.getterMethod.invoke(target);
			} catch (Exception e) {
				throw new ReflectionException(e, "Invoking getter attribute method "
						+ methodInfo.getterMethod.getName() + " on " + target.getClass() + " threw exception");
			}
		}
	}

	@Override
	public AttributeList getAttributes(String[] attributeNames) {
		AttributeList returnList = new AttributeList();
		for (String name : attributeNames) {
			try {
				returnList.add(new Attribute(name, getAttribute(name)));
			} catch (Exception e) {
				returnList.add(new Attribute(name, "Getting attribute threw: " + e.getMessage()));
			}
		}
		return returnList;
	}

	@Override
	public void setAttribute(Attribute attribute) throws AttributeNotFoundException, ReflectionException {
		AttributeMethodInfo methodInfo = attributeMethodMap.get(attribute.getName());
		if (methodInfo == null) {
			AttributeFieldInfo fieldInfo = attributeFieldMap.get(attribute.getName());
			if (fieldInfo == null || !fieldInfo.isSetter) {
				throwUnknownAttributeException(attribute.getName());
			}
			try {
				fieldInfo.field.set(target, attribute.getValue());
			} catch (Exception e) {
				throw new ReflectionException(e, "Invoking setter attribute on field " + fieldInfo.field.getName()
						+ " on " + target.getClass() + " threw exception");
			}
		} else {
			if (methodInfo.setterMethod == null) {
				throwUnknownAttributeException(attribute.getName());
			}
			try {
				methodInfo.setterMethod.invoke(target, attribute.getValue());
			} catch (Exception e) {
				throw new ReflectionException(e, "Invoking setter attribute method "
						+ methodInfo.setterMethod.getName() + " on " + target.getClass() + " threw exception");
			}
		}
	}

	@Override
	public AttributeList setAttributes(AttributeList attributes) {
		AttributeList returnList = new AttributeList(attributes.size());
		for (Attribute attribute : attributes.asList()) {
			String name = attribute.getName();
			try {
				setAttribute(attribute);
				returnList.add(new Attribute(name, getAttribute(name)));
			} catch (Exception e) {
				returnList.add(new Attribute(name, e.getMessage()));
			}
		}
		return returnList;
	}

	@Override
	public Object invoke(String actionName, Object[] params, String[] signatureTypes) throws MBeanException,
			ReflectionException {
		Method method = operationMethodMap.get(new NameParams(actionName, signatureTypes));
		if (method == null) {
			throw new MBeanException(new IllegalArgumentException("Unknown action '" + actionName
					+ "' with parameter types " + Arrays.toString(signatureTypes)));
		}
		try {
			return method.invoke(target, params);
		} catch (Exception e) {
			throw new ReflectionException(e, "Invoking operation method " + method.getName() + " on "
					+ target.getClass() + " threw exception");
		}
	}

	private static String preprocessDescription(Object target, String description) {
		if (description == null) {
			return "Information about " + target.getClass();
		} else {
			return description;
		}
	}

	/**
	 * Build our JMX information object by using reflection.
	 */
	private MBeanInfo buildMbeanInfo(JmxAttributeFieldInfo[] attributeFieldInfos,
			JmxAttributeMethodInfo[] attributeMethodInfos, JmxOperationInfo[] operationInfos, boolean ignoreErrors) {

		// NOTE: setup the maps that track previous class configuration
		Map<String, JmxAttributeFieldInfo> attributeFieldInfoMap = null;
		if (attributeFieldInfos != null) {
			attributeFieldInfoMap = new HashMap<String, JmxAttributeFieldInfo>();
			for (JmxAttributeFieldInfo info : attributeFieldInfos) {
				attributeFieldInfoMap.put(info.getFieldName(), info);
			}
		}
		Map<String, JmxAttributeMethodInfo> attributeMethodInfoMap = null;
		if (attributeMethodInfos != null) {
			attributeMethodInfoMap = new HashMap<String, JmxAttributeMethodInfo>();
			for (JmxAttributeMethodInfo info : attributeMethodInfos) {
				attributeMethodInfoMap.put(info.getMethodName(), info);
			}
		}
		Map<String, JmxOperationInfo> attributeOperationInfoMap = null;
		if (operationInfos != null) {
			attributeOperationInfoMap = new HashMap<String, JmxOperationInfo>();
			for (JmxOperationInfo info : operationInfos) {
				attributeOperationInfoMap.put(info.getMethodName(), info);
			}
		}

		Set<String> attributeNameSet = new HashSet<String>();
		List<MBeanAttributeInfo> attributes = new ArrayList<MBeanAttributeInfo>();
		// NOTE: methods override fields so subclasses can stop exposing of fields
		discoverAttributeMethods(attributeMethodInfoMap, attributeNameSet, attributes, ignoreErrors);
		discoverAttributeFields(attributeFieldInfoMap, attributeNameSet, attributes);
		List<MBeanOperationInfo> operations = discoverOperations(attributeOperationInfoMap);

		return new MBeanInfo(target.getClass().getName(), description,
				attributes.toArray(new MBeanAttributeInfo[attributes.size()]), null,
				operations.toArray(new MBeanOperationInfo[operations.size()]), null);
	}

	/**
	 * Find attribute methods from our object that will be exposed via JMX.
	 */
	private void discoverAttributeMethods(Map<String, JmxAttributeMethodInfo> attributeMethodInfoMap,
			Set<String> attributeNameSet, List<MBeanAttributeInfo> attributes, boolean ignoreErrors) {
		for (Class<?> clazz = target.getClass(); clazz != Object.class; clazz = clazz.getSuperclass()) {
			discoverAttributeMethods(attributeMethodInfoMap, attributeNameSet, attributes, ignoreErrors, clazz);
		}
		/*
		 * we have to go back and post process the attribute-method-map because the getter and setter methods change the
		 * method-info multiple times.
		 */
		for (AttributeMethodInfo methodInfo : attributeMethodMap.values()) {
			attributes.add(new MBeanAttributeInfo(methodInfo.varName, methodInfo.type.getName(),
					methodInfo.description, (methodInfo.getterMethod != null), (methodInfo.setterMethod != null),
					methodInfo.isIs()));
			attributeNameSet.add(methodInfo.varName);
		}
	}

	private void discoverAttributeMethods(Map<String, JmxAttributeMethodInfo> attributeMethodInfoMap,
			Set<String> attributeNameSet, List<MBeanAttributeInfo> attributes, boolean ignoreErrors, Class<?> clazz) {
		for (Method method : clazz.getMethods()) {
			JmxAttributeMethod jmxAttribute = method.getAnnotation(JmxAttributeMethod.class);
			JmxAttributeMethodInfo attributeMethodInfo = null;
			if (jmxAttribute == null) {
				// skip it if no annotation
				if (attributeMethodInfoMap != null) {
					// was this attribute method already configured?
					attributeMethodInfo = attributeMethodInfoMap.get(method.getName());
				}
				if (attributeMethodInfo == null) {
					continue;
				}
			} else {
				attributeMethodInfo = new JmxAttributeMethodInfo(method.getName(), jmxAttribute);
				jmxAttribute = null;
			}

			try {
				discoverAttributeMethod(attributeNameSet, method, attributeMethodInfo);
			} catch (IllegalArgumentException iae) {
				if (!ignoreErrors) {
					throw iae;
				}
			}
		}
	}

	private void discoverAttributeMethod(Set<String> attributeNameSet, Method method,
			JmxAttributeMethodInfo attributeMethodInfo) {
		String methodName = method.getName();
		boolean isIs;
		if (methodName.startsWith("is")) {
			if (method.getReturnType() != boolean.class && method.getReturnType() != Boolean.class) {
				throw new IllegalArgumentException("Method '" + method
						+ "' starts with 'is' but does not return a boolean or Boolean class");
			}
			isIs = true;
		} else {
			isIs = false;
		}
		String varName = buildMethodSuffix(method, methodName, isIs);
		if (attributeNameSet.contains(varName)) {
			return;
		}
		AttributeMethodInfo methodInfo = attributeMethodMap.get(varName);
		if (isIs || methodName.startsWith("get")) {
			if (method.getParameterTypes().length != 0) {
				throw new IllegalArgumentException("Method '" + method + "' starts with 'get' but has arguments");
			}
			if (method.getReturnType() == void.class) {
				throw new IllegalArgumentException("Method '" + method
						+ "' starts with 'get' but does not return anything");
			}
			if (methodInfo == null) {
				attributeMethodMap.put(varName, new AttributeMethodInfo(varName, attributeMethodInfo.getDescription(),
						method, null));
			} else {
				// setter must have already started our method-info, add the getter to it
				methodInfo.getterMethod = method;
			}
		} else if (methodName.startsWith("set")) {
			if (method.getParameterTypes().length != 1) {
				throw new IllegalArgumentException("Method '" + method
						+ "' starts with 'set' but does not have 1 argument");
			}
			if (method.getReturnType() != void.class) {
				throw new IllegalArgumentException("Method '" + method + "' starts with 'set' but does not return void");
			}
			if (methodInfo == null) {
				attributeMethodMap.put(varName, new AttributeMethodInfo(varName, attributeMethodInfo.getDescription(),
						null, method));
			} else {
				// getter must have already started our method-info, add the setter to it
				methodInfo.setterMethod = method;
			}
		} else {
			throw new IllegalArgumentException("Method '" + method
					+ "' is marked as an attribute but does not start with 'get' or 'set'");
		}
	}

	/**
	 * Find attribute methods from our object that will be exposed via JMX.
	 */
	private void discoverAttributeFields(Map<String, JmxAttributeFieldInfo> attributeFieldInfoMap,
			Set<String> attributeNameSet, List<MBeanAttributeInfo> attributes) {
		for (Class<?> clazz = target.getClass(); clazz != Object.class; clazz = clazz.getSuperclass()) {
			discoverAttributeFields(attributeFieldInfoMap, attributeNameSet, attributes, clazz);
		}
	}

	private void discoverAttributeFields(Map<String, JmxAttributeFieldInfo> attributeFieldInfoMap,
			Set<String> attributeNameSet, List<MBeanAttributeInfo> attributes, Class<?> clazz) {
		Field[] fields = clazz.getDeclaredFields();
		for (Field field : fields) {
			String fieldName = field.getName();
			if (attributeNameSet.contains(fieldName)) {
				continue;
			}
			JmxAttributeField attributeField = field.getAnnotation(JmxAttributeField.class);
			JmxAttributeFieldInfo attributeFieldInfo = null;
			if (attributeField == null) {
				if (attributeFieldInfoMap != null) {
					// was this attribute field already configured?
					attributeFieldInfo = attributeFieldInfoMap.get(fieldName);
				}
				if (attributeFieldInfo == null) {
					continue;
				}
			} else {
				attributeFieldInfo = new JmxAttributeFieldInfo(fieldName, attributeField);
				attributeField = null;
			}

			if (!field.isAccessible()) {
				field.setAccessible(true);
			}
			attributeFieldMap.put(fieldName, new AttributeFieldInfo(field, attributeFieldInfo.isReadible(),
					attributeFieldInfo.isWritable()));

			String description = attributeFieldInfo.getDescription();
			if (isEmpty(description)) {
				description = fieldName + " attribute";
			}

			boolean isIs;
			if (fieldName.startsWith("is") && (field.getType() == boolean.class || field.getType() == Boolean.class)) {
				isIs = true;
			} else {
				isIs = false;
			}
			attributes.add(new MBeanAttributeInfo(fieldName, field.getType().getName(), description,
					attributeFieldInfo.isReadible(), attributeFieldInfo.isWritable(), isIs));
			attributeNameSet.add(fieldName);
		}
	}

	/**
	 * Find operation methods from our object that will be exposed via JMX.
	 */
	private List<MBeanOperationInfo> discoverOperations(Map<String, JmxOperationInfo> attributeOperationInfoMap) {
		Set<MethodSignature> methodSignatureSet = new HashSet<MethodSignature>();
		List<MBeanOperationInfo> operations = new ArrayList<MBeanOperationInfo>(operationMethodMap.size());
		for (Class<?> clazz = target.getClass(); clazz != Object.class; clazz = clazz.getSuperclass()) {
			discoverOperations(attributeOperationInfoMap, methodSignatureSet, operations, clazz);
		}
		return operations;
	}

	private void discoverOperations(Map<String, JmxOperationInfo> attributeOperationInfoMap,
			Set<MethodSignature> methodSignatureSet, List<MBeanOperationInfo> operations, Class<?> clazz) {
		for (Method method : clazz.getMethods()) {
			MethodSignature methodSignature = new MethodSignature(method);
			if (methodSignatureSet.contains(methodSignature)) {
				continue;
			}
			String methodName = method.getName();
			JmxOperation jmxOperation = method.getAnnotation(JmxOperation.class);
			JmxOperationInfo operationInfo = null;
			if (jmxOperation == null) {
				if (attributeOperationInfoMap != null) {
					// was this operation already configured?
					operationInfo = attributeOperationInfoMap.get(methodName);
				}
				if (operationInfo == null) {
					continue;
				}
			} else {
				operationInfo = new JmxOperationInfo(methodName, jmxOperation);
				jmxOperation = null;
			}
			if (methodName.startsWith("get") || methodName.startsWith("is") || methodName.startsWith("set")) {
				throw new IllegalArgumentException("Operation method " + method
						+ " cannot start with 'get', 'is', or 'set'.  Did you use the wrong annotation?");
			}
			Class<?>[] types = method.getParameterTypes();
			String[] stringTypes = new String[types.length];
			for (int i = 0; i < types.length; i++) {
				stringTypes[i] = types[i].getName();
			}
			NameParams nameParams = new NameParams(methodName, stringTypes);
			MBeanParameterInfo[] parameterInfos = buildOperationParameterInfo(method, operationInfo);
			operationMethodMap.put(nameParams, method);

			String description = operationInfo.getDescription();
			if (isEmpty(description)) {
				description = methodName + " operation";
			}

			operations.add(new MBeanOperationInfo(methodName, description, parameterInfos, method.getReturnType()
					.getName(), operationInfo.getAction().getActionValue()));
			methodSignatureSet.add(methodSignature);
		}
	}

	/**
	 * Build our parameter information for an operation.
	 */
	private MBeanParameterInfo[] buildOperationParameterInfo(Method method, JmxOperationInfo operationInfo) {
		Class<?>[] types = method.getParameterTypes();
		MBeanParameterInfo[] parameterInfos = new MBeanParameterInfo[types.length];
		String[] parameterNames = operationInfo.getParameterNames();
		String[] parameterDescriptions = operationInfo.getParameterDescriptions();
		for (int i = 0; i < types.length; i++) {
			String parameterName;
			if (parameterNames == null || i >= parameterNames.length) {
				parameterName = "p" + (i + 1);
			} else {
				parameterName = parameterNames[i];
			}
			String typeName = types[i].getName();
			String description;
			if (parameterDescriptions == null || i >= parameterDescriptions.length) {
				description = "parameter #" + (i + 1) + " of type: " + typeName;
			} else {
				description = parameterDescriptions[i];
			}
			parameterInfos[i] = new MBeanParameterInfo(parameterName, typeName, description);
		}
		return parameterInfos;
	}

	private String buildMethodSuffix(Method method, String methodName, boolean isIs) {
		if (isIs) {
			if (methodName.length() < 3) {
				throw new IllegalArgumentException("Method '" + methodName + "' has a name that is too short");
			}
			return Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
		} else {
			if (methodName.length() < 4) {
				throw new IllegalArgumentException("Method '" + methodName + "' has a name that is too short");
			}
			return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
		}
	}

	/**
	 * We do this to standardize our exceptions around unknown attributes.
	 */
	private void throwUnknownAttributeException(String attributeName) throws AttributeNotFoundException {
		throw new AttributeNotFoundException("Unknown attribute " + attributeName);
	}

	private static boolean isEmpty(String string) {
		return string == null || string.trim().length() == 0;
	}

	/**
	 * Key class for our hashmap to find matching methods based on name and parameter list.
	 */
	private static class NameParams {
		String name;
		String[] paramTypes;
		public NameParams(String name, String[] paramTypes) {
			this.name = name;
			this.paramTypes = paramTypes;
		}

		@Override
		public int hashCode() {
			int hashCode = 31 * (31 + name.hashCode());
			if (paramTypes != null) {
				hashCode += Arrays.hashCode(paramTypes);
			}
			return hashCode;
		}

		@Override
		public boolean equals(Object obj) {
			if (obj == null || getClass() != obj.getClass()) {
				return false;
			}
			NameParams other = (NameParams) obj;
			if (!this.name.equals(other.name)) {
				return false;
			}
			return Arrays.equals(this.paramTypes, other.paramTypes);
		}
	}

	/**
	 * Information about attribute methods.
	 */
	private static class AttributeMethodInfo {
		final String varName;
		final String description;
		Method getterMethod;
		Method setterMethod;
		final Class<?> type;

		public AttributeMethodInfo(String varName, String description, Method getterMethod, Method setterMethod) {
			this.varName = varName;
			if (isEmpty(description)) {
				this.description = varName + " attribute";
			} else {
				this.description = description;
			}
			this.getterMethod = getterMethod;
			this.setterMethod = setterMethod;
			if (getterMethod == null) {
				type = setterMethod.getParameterTypes()[0];
			} else {
				type = getterMethod.getReturnType();
			}
		}

		public boolean isIs() {
			if (getterMethod != null && getterMethod.getName().startsWith("is")
					&& (type == boolean.class || type == Boolean.class)) {
				return true;
			} else {
				return false;
			}
		}
	}

	/**
	 * Information about attribute fields
	 */
	private static class AttributeFieldInfo {

		final Field field;
		final boolean isGetter;
		final boolean isSetter;

		public AttributeFieldInfo(Field field, boolean isGetter, boolean isSetter) {
			this.field = field;
			this.isGetter = isGetter;
			this.isSetter = isSetter;
		}
	}

	/**
	 * Method signature that matches the name and parameter-types. We don't care about return type because Java doesn't
	 * match methods using it.
	 */
	private static class MethodSignature {

		final String name;
		final Class<?>[] parameterTypes;

		private MethodSignature(Method method) {
			this.name = method.getName();
			this.parameterTypes = method.getParameterTypes();
		}

		@Override
		public int hashCode() {
			final int prime = 31;
			int result = prime + name.hashCode();
			result = prime * result + Arrays.hashCode(parameterTypes);
			return result;
		}

		@Override
		public boolean equals(Object obj) {
			if (obj == null || getClass() != obj.getClass()) {
				return false;
			}
			MethodSignature other = (MethodSignature) obj;
			return name.equals(other.name) && Arrays.equals(parameterTypes, other.parameterTypes);
		}
	}
}