package io.crnk.validation.internal;

import io.crnk.core.engine.document.ErrorData;
import io.crnk.core.engine.document.ErrorDataBuilder;
import io.crnk.core.engine.error.ErrorResponse;
import io.crnk.core.engine.error.ExceptionMapper;
import io.crnk.core.engine.error.ExceptionMapperHelper;
import io.crnk.core.engine.http.HttpRequestContext;
import io.crnk.core.engine.http.HttpRequestContextProvider;
import io.crnk.core.engine.http.HttpStatus;
import io.crnk.core.engine.information.resource.ResourceField;
import io.crnk.core.engine.information.resource.ResourceFieldAccessor;
import io.crnk.core.engine.information.resource.ResourceFieldType;
import io.crnk.core.engine.information.resource.ResourceInformation;
import io.crnk.core.engine.internal.utils.ExceptionUtil;
import io.crnk.core.engine.internal.utils.PreconditionUtil;
import io.crnk.core.engine.internal.utils.PropertyUtils;
import io.crnk.core.engine.query.QueryContext;
import io.crnk.core.engine.registry.RegistryEntry;
import io.crnk.core.engine.registry.ResourceRegistry;
import io.crnk.core.module.Module.ModuleContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import javax.validation.ElementKind;
import javax.validation.Path.Node;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;

public class ConstraintViolationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {

	protected static final String META_RESOURCE_ID = "resourceId";

	protected static final String META_RESOURCE_TYPE = "resourceType";

	protected static final String META_TYPE_KEY = "type";

	protected static final String META_TYPE_VALUE = "ConstraintViolation";

	protected static final String META_MESSAGE_TEMPLATE = "messageTemplate";

	private static final Logger LOGGER = LoggerFactory.getLogger(ConstraintViolationExceptionMapper.class);

	private static final String HIBERNATE_PROPERTY_NODE_IMPL = "org.hibernate.validator.path.PropertyNode";

	private static final Object HIBERNATE_PROPERTY_NODE_ENGINE_IMPL = "org.hibernate.validator.internal.engine.path.NodeImpl";

	private static final String DEFAULT_PRIMARY_KEY_NAME = "id";

	private ModuleContext context;

	public ConstraintViolationExceptionMapper(ModuleContext context) {
		this.context = context;
	}

	private static Object getValue(final Node propertyNode) {
		// bean validation not sufficient for sets
		// not possible to access elements, reverting to
		// Hibernate implementation
		// TODO investigate other implementation next to
		// hibernate, JSR 303 v1.1 not sufficient

		checkNodeImpl(propertyNode);

		return ExceptionUtil.wrapCatchedExceptions(new Callable<Object>() {

			@Override
			public Object call() throws Exception {
				Method parentMethod = propertyNode.getClass().getMethod("getParent");
				Method valueMethod = propertyNode.getClass().getMethod("getValue");
				Object parentNode = parentMethod.invoke(propertyNode);
				if (parentNode != null) {
					return valueMethod.invoke(parentNode);
				} else {
					return valueMethod.invoke(propertyNode);
				}
			}
		});
	}

	private static void checkNodeImpl(Node propertyNode) {
		boolean hibernateNodeImpl =
				propertyNode.getClass().getName().equals(HIBERNATE_PROPERTY_NODE_IMPL); // NOSONAR class / may not be available
		boolean hiberanteNodeImpl2 = propertyNode.getClass().getName().equals(HIBERNATE_PROPERTY_NODE_ENGINE_IMPL); // NOSONAR;

		PreconditionUtil.assertTrue("cannot convert violations for java.util.Set elements, consider using Hibernate validator",
				hibernateNodeImpl || hiberanteNodeImpl2);
	}

	private static Object getParameterValue(final Node propertyNode) {
		// bean validation not sufficient for sets
		// not possible to access elements, reverting to
		// Hibernate implementation
		// TODO investigate other implementation next to
		// hibernate, JSR 303 v1.1 not sufficient
		checkNodeImpl(propertyNode);

		return ExceptionUtil.wrapCatchedExceptions(new Callable<Object>() {

			@Override
			public Object call() throws Exception {
				Method valueMethod = propertyNode.getClass().getMethod("getValue");
				return valueMethod.invoke(propertyNode);
			}
		});
	}

	@Override
	public ErrorResponse toErrorResponse(ConstraintViolationException cve) {
		LOGGER.warn("a ConstraintViolationException occured", cve);

		List<ErrorData> errors = new ArrayList<>();
		for (ConstraintViolation<?> violation : cve.getConstraintViolations()) {

			ErrorDataBuilder builder = ErrorData.builder();
			builder = builder.addMetaField(META_TYPE_KEY, META_TYPE_VALUE);
			builder = builder.setStatus(String.valueOf(HttpStatus.UNPROCESSABLE_ENTITY_422));
			builder = builder.setDetail(violation.getMessage());

			builder = builder.setCode(toCode(violation));
			if (violation.getMessageTemplate() != null) {
				builder = builder.addMetaField(META_MESSAGE_TEMPLATE, violation.getMessageTemplate());
			}

			// for now we just provide root resource validation information
			// depending on bulk update spec, we might also provide the leaf information in the future
			if (violation.getRootBean() != null) {
				ResourceRef resourceRef = resolvePath(violation);
				builder = builder.addMetaField(META_RESOURCE_ID, resourceRef.getRootResourceId());
				builder = builder.addMetaField(META_RESOURCE_TYPE, resourceRef.getRootResourceType());
				builder = builder.setSourcePointer(resourceRef.getRootSourcePointer());
			}

			ErrorData error = builder.build();
			errors.add(error);
		}

		return ErrorResponse.builder().setStatus(HttpStatus.UNPROCESSABLE_ENTITY_422).setErrorData(errors).build();
	}

	private String toCode(ConstraintViolation<?> violation) {
		if (violation.getConstraintDescriptor() != null) {
			Annotation annotation = violation.getConstraintDescriptor().getAnnotation();

			if (annotation != null) {
				Class<?> clazz = annotation.getClass();
				Class<?> superclass = annotation.getClass().getSuperclass();
				Class<?>[] interfaces = annotation.getClass().getInterfaces();
				if (superclass == Proxy.class && interfaces.length == 1) {
					clazz = interfaces[0];
				}

				return clazz.getName();
			}
		}
		if (violation.getMessageTemplate() != null) {
			return violation.getMessageTemplate().replace("{", "").replaceAll("}", "");
		}
		return null;
	}

	@Override
	@SuppressWarnings({"rawtypes", "unchecked"})
	public ConstraintViolationException fromErrorResponse(ErrorResponse errorResponse) {
		Set violations = new HashSet();

		StringBuilder message = new StringBuilder();

		HttpRequestContextProvider httpRequestContextProvider = context.getModuleRegistry().getHttpRequestContextProvider();
		HttpRequestContext requestContext = httpRequestContextProvider.getRequestContext();
		QueryContext queryContext = requestContext.getQueryContext();

		Iterable<ErrorData> errors = errorResponse.getErrors();
		for (ErrorData error : errors) {
			ConstraintViolationImpl violation = ConstraintViolationImpl.fromError(context.getResourceRegistry(), error, queryContext);
			violations.add(violation);

			// TODO cleanup message handling
			if (message.length() > 0) {
				message.append(", ");
			}
			if (violation.getMessage() != null) {
				message.append(violation.getMessage());
			} else if (error.getDetail() != null) {
				message.append(error.getDetail());
			} else {
				message.append(error.getCode());
			}
			String sourcePointer = error.getSourcePointer();
			if (sourcePointer != null) {
				message.append(" (" + sourcePointer + ")");
			}
		}

		return new ConstraintViolationException(message.toString(), violations);
	}

	@Override
	public boolean accepts(ErrorResponse errorResponse) {
		return ExceptionMapperHelper.accepts(errorResponse, HttpStatus.UNPROCESSABLE_ENTITY_422, META_TYPE_VALUE);
	}

	/**
	 * Translate validated bean and root path into validated resource and
	 * resource path. For example, embeddables belonging to an entity document
	 * are mapped back to an entity violation and a proper path to the
	 * embeddable attribute.
	 *
	 * @param violation to compute the reference
	 * @return computaed reference
	 */
	private ResourceRef resolvePath(ConstraintViolation<?> violation) {
		Object resource = violation.getRootBean();

		Object nodeObject = resource;
		ResourceRef ref = new ResourceRef(resource);

		Iterator<Node> iterator = violation.getPropertyPath().iterator();
		while (iterator.hasNext()) {
			Node node = iterator.next();

			// ignore methods/parameters
			if (node.getKind() == ElementKind.METHOD) {
				continue;
			}
			if (node.getKind() == ElementKind.PARAMETER) {
				resource = getParameterValue(node);
				nodeObject = resource;
				ref = new ResourceRef(resource);
				assertResource(resource);
				continue;
			}

			// visit list, set, map references
			nodeObject = ref.getNodeReference(nodeObject, node);
			ref.visitNode(nodeObject);

			// visit property
			nodeObject = ref.visitProperty(nodeObject, node);
		}

		return ref;
	}

	private void assertResource(Object resource) {
		if (!isResource(resource.getClass())) {
			throw new IllegalStateException("a resource must be used as root, got " + resource + " instead");
		}
	}

	private boolean isResource(Class<?> clazz) {
		ResourceRegistry resourceRegistry = context.getResourceRegistry();
		return resourceRegistry.hasEntry(clazz);
	}

	/**
	 * @param resource to get the id from
	 * @return id of the given resource
	 */
	protected String getResourceId(Object resource) {
		ResourceRegistry resourceRegistry = context.getResourceRegistry();
		RegistryEntry entry = resourceRegistry.findEntry(resource.getClass());
		ResourceInformation resourceInformation = entry.getResourceInformation();
		ResourceField idField = resourceInformation.getIdField();
		Object id = idField.getAccessor().getValue(resource);
		if (id != null) {
			return id.toString();
		}
		return null;
	}

	protected String getResourceType(Object resource) {
		ResourceRegistry resourceRegistry = context.getResourceRegistry();
		RegistryEntry entry = resourceRegistry.findEntry(resource.getClass());
		ResourceInformation resourceInformation = entry.getResourceInformation();
		return resourceInformation.getResourceType();
	}

	class ResourceRef {

		private Object rootResource;

		private Object leafResource;

		private StringBuilder rootSourcePointer = new StringBuilder();

		private StringBuilder leafSourcePointer = new StringBuilder();

		public ResourceRef(Object resource) {
			this.leafResource = resource;
			this.rootResource = resource;
		}

		public String getRootSourcePointer() {
			return rootSourcePointer.toString();
		}

		public Object visitProperty(Object nodeObject, Node node) {
			ResourceRegistry resourceRegistry = context.getResourceRegistry();
			Class nodeClass = nodeObject.getClass();
			ResourceInformation resourceInformation = null;
			if (resourceRegistry.hasEntry(nodeClass)) {
				RegistryEntry entry = resourceRegistry.getEntry(nodeClass);
				resourceInformation = entry.getResourceInformation();
			}

			String name = node.getName();

			Object next;
			if (node.getKind() == ElementKind.PROPERTY) {
				if (resourceRegistry.hasEntry(nodeClass)) {
					ResourceFieldAccessor accessor = resourceInformation.getAccessor(name);
					if (accessor != null) {
						next = accessor.getValue(nodeObject);
					} else {
						next = PropertyUtils.getProperty(nodeObject, name);
					}
				} else {
					next = PropertyUtils.getProperty(nodeObject, name);
				}
			} else if (node.getKind() == ElementKind.BEAN) {
				next = nodeObject;
			} else {
				throw new UnsupportedOperationException("unknown node: " + node);
			}

			if (name != null) {
				ResourceField resourceField =
						resourceInformation != null ? resourceInformation.findFieldByUnderlyingName(name) : null;

				String mappedName = name;
				if (resourceField != null) {
					// in case of @JsonApiRelationId it will be mapped to original name
					resourceField = resourceInformation.findFieldByUnderlyingName(resourceField.getUnderlyingName());
					mappedName = resourceField.getJsonName();
				}

				appendSeparator();
				if (resourceField == null || resourceField.getResourceFieldType() == ResourceFieldType.ID) {
					// continue along attributes path or primary key on root
					appendSourcePointer(mappedName);
				} else if (resourceField != null && resourceField.getResourceFieldType() == ResourceFieldType.RELATIONSHIP) {
					appendSourcePointer("/data/relationships/");
					appendSourcePointer(mappedName);
				} else {

					appendSourcePointer("/data/attributes/");
					appendSourcePointer(mappedName);
				}
			}
			return next;
		}

		private Object getNodeReference(Object element, Node node) {
			Integer index = node.getIndex();
			Object key = node.getKey();
			if (index != null) {
				appendSeparator();
				appendSourcePointer(index);
				return ((List<?>) element).get(index);
			} else if (key != null) {
				appendSeparator();
				appendSourcePointer(key);
				return ((Map<?, ?>) element).get(key);
			} else if (element instanceof Set && getValue(node) != null) {
				Object elementEntry = getValue(node);

				// since sets get translated to arrays, we do the same here
				// crnk-client allocates sets that preserver the order
				// of arrays
				List<Object> list = new ArrayList<>();
				list.addAll((Set<?>) element);
				index = list.indexOf(elementEntry);

				appendSeparator();
				appendSourcePointer(index);

				return getValue(node);
			}
			return element;
		}

		private void appendSourcePointer(Object object) {
			leafSourcePointer.append(object);
			if (!withinRelation()) {
				// bulk update of resources not support by json api spec,
				//so we stop for sourcePointer computation when a relation
				// could not be validated. How to continue depends on future implementations
				rootSourcePointer.append(object);
			}
		}

		private boolean withinRelation() {
			return rootResource != leafResource;
		}

		private void appendSeparator() {
			if (leafSourcePointer.length() > 0) {
				appendSourcePointer("/");
			}
		}

		public void visitNode(Object nodeValue) {
			boolean isResource = nodeValue != null && isResource(nodeValue.getClass());
			if (isResource) {
				leafSourcePointer = new StringBuilder();
				leafResource = nodeValue;
			}
		}

		public Object getRootResourceId() {
			return ConstraintViolationExceptionMapper.this.getResourceId(rootResource);
		}

		public Object getRootResourceType() {
			return ConstraintViolationExceptionMapper.this.getResourceType(rootResource);
		}
	}
}