/*
 * Copyright (c) 2014. Escalon System-Entwicklung, Dietrich Schulten
 *
 * 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 de.escalon.hypermedia.spring.hydra;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import de.escalon.hypermedia.PropertyUtils;
import de.escalon.hypermedia.action.Cardinality;
import de.escalon.hypermedia.action.Input;
import de.escalon.hypermedia.affordance.*;
import de.escalon.hypermedia.hydra.mapping.Expose;
import de.escalon.hypermedia.hydra.serialize.JacksonHydraSerializer;
import de.escalon.hypermedia.hydra.serialize.JsonLdKeywords;
import de.escalon.hypermedia.hydra.serialize.LdContext;
import de.escalon.hypermedia.hydra.serialize.LdContextFactory;
import de.escalon.hypermedia.spring.SpringActionInputParameter;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.hateoas.IanaRels;
import org.springframework.hateoas.Link;
import org.springframework.util.Assert;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.*;

/**
 * Serializer to convert Link to json-ld representation. Created by dschulten on 19.09.2014.
 */
public class LinkListSerializer extends StdSerializer<List<Link>> {

    Logger LOG = LoggerFactory.getLogger(LinkListSerializer.class);

    private static final String IANA_REL_PREFIX = "urn:iana:link-relations:";

    public LinkListSerializer() {
        super(List.class, false);
    }

    @Override
    public void serialize(List<Link> links, JsonGenerator jgen,
                          SerializerProvider serializerProvider) throws IOException {

        try {
            Collection<Link> simpleLinks = new ArrayList<Link>();
            Collection<Affordance> affordances = new ArrayList<Affordance>();
            Collection<Link> templatedLinks = new ArrayList<Link>();
            Collection<Affordance> collectionAffordances = new ArrayList<Affordance>();
            Link selfRel = null;
            for (Link link : links) {
                if (link instanceof Affordance) {
                    final Affordance affordance = (Affordance) link;
                    final List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors();
                    if (!actionDescriptors.isEmpty()) {
                        // TODO: consider to use Link href for template even if it is not compatible
                        if (affordance.getUriTemplateComponents()
                                .hasVariables()) {
                            // TODO resolve rel against context
                            if ("hydra:search".equals(affordance.getRel())
                                    || Cardinality.SINGLE == affordance
                                    .getCardinality()) {
                                templatedLinks.add(affordance);
                            } else {
                                collectionAffordances.add(affordance);
                            }
                        } else {
                            // if all required variables are satisfied, the url can be used as identifier
                            // by stripping optional variables
                            if (!affordance.isSelfRel() && Cardinality.COLLECTION == affordance.getCardinality()) {
                                collectionAffordances.add(affordance);
                            } else {
                                affordances.add(affordance);
                            }
                        }
                    } else {
                        if (affordance.isTemplated()) {
                            templatedLinks.add(affordance);
                        } else {
                            simpleLinks.add(affordance);
                        }
                    }
                } else if (link.isTemplated()) {
                    templatedLinks.add(link);
                } else {
                    simpleLinks.add(link);
                }
                if ("self".equals(link.getRel())) {
                    selfRel = link;
                }
            }

            for (Link templatedLink : templatedLinks) {
                // templated affordance might turn out to have all variables satisfied or
                // only optional unsatisfied variables
                ActionDescriptor actionDescriptorForHttpGet = getActionDescriptorForHttpGet(templatedLink);
                // TODO handle rev here
                String rel = templatedLink.getRel();
                writeIriTemplate(rel, templatedLink.getHref(), templatedLink.getVariableNames(),
                        actionDescriptorForHttpGet, jgen);
            }
            @SuppressWarnings("unchecked")
            Deque<LdContext> contextStack = (Deque<LdContext>) serializerProvider.getAttribute(JacksonHydraSerializer
                    .KEY_LD_CONTEXT);
            String currentVocab = (contextStack != null && !contextStack.isEmpty()) ?
                    contextStack.peek().vocab : null;

            // related collections
            if (!collectionAffordances.isEmpty()) {

                jgen.writeArrayFieldStart("hydra:collection");

                for (Affordance collectionAffordance : collectionAffordances) {
                    jgen.writeStartObject();
                    jgen.writeStringField(JsonLdKeywords.AT_TYPE, "hydra:Collection");
                    PartialUriTemplateComponents templateComponents =
                            collectionAffordance.getUriTemplateComponents();
                    if (!collectionAffordance.isBaseUriTemplated() &&
                            !collectionAffordance.hasUnsatisfiedRequiredVariables()) {
                        String collectionUri = templateComponents.getBaseUri()
                                + templateComponents.getQueryHead();
                        jgen.writeStringField(JsonLdKeywords.AT_ID, collectionUri);
                    }
                    if (templateComponents.hasVariables()) {
                        ActionDescriptor actionDescriptorForHttpGet = getActionDescriptorForHttpGet
                                (collectionAffordance);
                        writeIriTemplate("hydra:search", templateComponents.toString(),
                                templateComponents.getVariableNames(), actionDescriptorForHttpGet,
                                jgen);
                    }
                    jgen.writeObjectFieldStart("hydra:manages");
                    // do we have a collection holder which is not owner of the affordance?
                    TypedResource collectionHolder = collectionAffordance.getCollectionHolder();
                    if (collectionAffordance.getRev() != null) {
                        jgen.writeStringField("hydra:property", collectionAffordance.getRev());
                        if (collectionHolder != null) {
                            // can't use writeObjectField, it won't inherit the context stack
                            writeCollectionHolder("hydra:object", collectionHolder, jgen);
                        } else if (selfRel != null) {
                            jgen.writeStringField("hydra:object", selfRel.getHref());
                        }
                    } else if (collectionAffordance.getRel() != null) {
                        jgen.writeStringField("hydra:property", collectionAffordance.getRel());
                        if (collectionHolder != null) {
                            // can't use writeObjectField, it won't inherit the context stack
                            writeCollectionHolder("hydra:subject", collectionHolder, jgen);
                        } else if (selfRel != null) {
                            jgen.writeStringField("hydra:subject", selfRel.getHref());
                        }
                    }
                    jgen.writeEndObject(); // end manages

                    List<ActionDescriptor> actionDescriptors = collectionAffordance.getActionDescriptors();
                    if (!actionDescriptors.isEmpty()) {
                        jgen.writeArrayFieldStart("hydra:operation");
                    }
                    writeActionDescriptors(jgen, currentVocab, actionDescriptors);
                    if (!actionDescriptors.isEmpty()) {
                        jgen.writeEndArray(); // end hydra:operation
                    }

                    jgen.writeEndObject(); // end collection
                }
                jgen.writeEndArray();
            }

            for (Affordance affordance : affordances) {
                final String rel = affordance.getRel();
                List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors();

                if (!actionDescriptors.isEmpty()) {
                    if (!Link.REL_SELF.equals(rel)) {
                        jgen.writeObjectFieldStart(rel); // begin rel
                    }
                    jgen.writeStringField(JsonLdKeywords.AT_ID, affordance.getHref());
                    jgen.writeArrayFieldStart("hydra:operation");
                }

                writeActionDescriptors(jgen, currentVocab, actionDescriptors);

                if (!actionDescriptors.isEmpty()) {
                    jgen.writeEndArray(); // end hydra:operation

                    if (!Link.REL_SELF.equals(rel)) {
                        jgen.writeEndObject(); // end rel
                    }
                }
            }

            for (Link simpleLink : simpleLinks) {
                final String rel = simpleLink.getRel();
                if (Link.REL_SELF.equals(rel)) {
                    jgen.writeStringField("@id", simpleLink.getHref());
                } else {
                    String linkAttributeName = IanaRels.isIanaRel(rel) ? IANA_REL_PREFIX + rel : rel;
                    jgen.writeObjectFieldStart(linkAttributeName);
                    jgen.writeStringField("@id", simpleLink.getHref());
                    jgen.writeEndObject();
                }
            }
        } catch (IntrospectionException e) {
            throw new RuntimeException(e);
        }
    }

    private void writeIriTemplate(String rel, String href, List<String> variableNames, ActionDescriptor
            actionDescriptorForHttpGet,
                                  JsonGenerator jgen) throws IOException {
        jgen.writeObjectFieldStart(rel);

        jgen.writeStringField("@type", "hydra:IriTemplate");
        jgen.writeStringField("hydra:template", href);
        jgen.writeArrayFieldStart("hydra:mapping");
        writeHydraVariableMapping(jgen, actionDescriptorForHttpGet, variableNames);
        jgen.writeEndArray();

        jgen.writeEndObject();
    }

    private void writeCollectionHolder(String fieldName, TypedResource collectionHolder, JsonGenerator jgen) throws
            IOException {
        jgen.writeObjectFieldStart(fieldName);
        String identifyingUri = collectionHolder.getIdentifyingUri();
        if (identifyingUri != null) {
            jgen.writeStringField(JsonLdKeywords.AT_ID, identifyingUri);
        }
        jgen.writeStringField(JsonLdKeywords.AT_TYPE, collectionHolder.getSemanticType());
        jgen.writeEndObject();
    }

    @Nullable
    private ActionDescriptor getActionDescriptorForHttpGet(Link templatedAffordance) {
        if (!(templatedAffordance instanceof Affordance)) {
            return null;
        }
        final List<ActionDescriptor> actionDescriptors = ((Affordance) templatedAffordance).getActionDescriptors();
        ActionDescriptor actionDescriptorGet = null;
        for (ActionDescriptor actionDescriptor : actionDescriptors) {
            String httpMethod = actionDescriptor.getHttpMethod();
            if ("GET".equalsIgnoreCase(httpMethod)) {
                actionDescriptorGet = actionDescriptor;
            }
        }
        return actionDescriptorGet;
    }

    private void writeActionDescriptors(JsonGenerator jgen, String currentVocab, List<ActionDescriptor>
            actionDescriptors) throws IOException, IntrospectionException {
        for (ActionDescriptor actionDescriptor : actionDescriptors) {
            jgen.writeStartObject(); // begin a hydra:Operation

            final String semanticActionType = actionDescriptor.getSemanticActionType();
            if (semanticActionType != null) {
                jgen.writeStringField("@type", semanticActionType);
            }
            jgen.writeStringField("hydra:method", actionDescriptor.getHttpMethod());

            final ActionInputParameter requestBodyInputParameter = actionDescriptor.getRequestBody();
            if (requestBodyInputParameter != null) {

                jgen.writeObjectFieldStart("hydra:expects"); // begin hydra:expects

                final Class<?> clazz = requestBodyInputParameter.getParameterType();
                final Expose classExpose = clazz.getAnnotation(Expose.class);
                final String typeName;
                if (classExpose != null) {
                    typeName = classExpose.value();
                } else {
                    typeName = requestBodyInputParameter.getParameterType()
                            .getSimpleName();
                }
                jgen.writeStringField("@type", typeName);

                jgen.writeArrayFieldStart("hydra:supportedProperty"); // begin hydra:supportedProperty
                // TODO check need for allRootParameters and requestBodyInputParameter here:
                recurseSupportedProperties(jgen, currentVocab, clazz, actionDescriptor,
                        requestBodyInputParameter, requestBodyInputParameter.getValue(), "");
                jgen.writeEndArray(); // end hydra:supportedProperty

                jgen.writeEndObject(); // end hydra:expects
            }

            jgen.writeEndObject(); // end hydra:Operation
        }
    }

    /**
     * Writes bean description recursively.
     *
     * @param jgen              to write to
     * @param currentVocab      in context
     * @param valueType         class of value
     * @param allRootParameters of the method that receives the request body
     * @param rootParameter     the request body
     * @param currentCallValue  the value at the current recursion level
     * @param propertyPath      of the current recursion level
     * @throws IntrospectionException
     * @throws IOException
     */
    private void recurseSupportedProperties(JsonGenerator jgen, String currentVocab, Class<?>
            valueType, ActionDescriptor allRootParameters,
                                            ActionInputParameter rootParameter, Object currentCallValue,
                                            String propertyPath)
            throws IntrospectionException,
            IOException {

        Map<String, ActionInputParameter> properties = new HashMap<String, ActionInputParameter>();

        // collect supported properties from ctor

        Constructor[] constructors = valueType.getConstructors();
        // find default ctor
        Constructor constructor = PropertyUtils.findDefaultCtor(constructors);
        // find ctor with JsonCreator ann
        if (constructor == null) {
            constructor = PropertyUtils.findJsonCreator(constructors, JsonCreator.class);
        }
        if (constructor == null) {
            // TODO this can be a generic collection, find a way to describe it
            LOG.warn("can't describe supported properties, no default constructor or JsonCreator found for type " + valueType
                    .getName());
            return;
        }

        int parameterCount = constructor.getParameterTypes().length;
        if (parameterCount > 0) {
            Annotation[][] annotationsOnParameters = constructor.getParameterAnnotations();

            Class[] parameters = constructor.getParameterTypes();
            int paramIndex = 0;
            for (Annotation[] annotationsOnParameter : annotationsOnParameters) {
                for (Annotation annotation : annotationsOnParameter) {
                    if (JsonProperty.class == annotation.annotationType()) {
                        JsonProperty jsonProperty = (JsonProperty) annotation;
                        // TODO use required attribute of JsonProperty
                        String paramName = jsonProperty.value();

                        Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, paramName);

                        ActionInputParameter constructorParamInputParameter =
                                new SpringActionInputParameter(
                                        new MethodParameter(constructor, paramIndex), propertyValue);

                        // TODO collect ctor params, setter params and process
                        // TODO then handle single, collection and bean for both
                        properties.put(paramName, constructorParamInputParameter);
                        paramIndex++; // increase for each @JsonProperty
                    }
                }
            }
            Assert.isTrue(parameters.length == paramIndex,
                    "not all constructor arguments of @JsonCreator " + constructor.getName() +
                            " are annotated with @JsonProperty");
        }

        // collect supported properties from setters

        // TODO support Option provider by other method args?
        final BeanInfo beanInfo = Introspector.getBeanInfo(valueType);
        final PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors();
        // TODO collection and map
        // TODO distinguish which properties should be printed as supported - now just setters
        for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
            final Method writeMethod = propertyDescriptor.getWriteMethod();
            if (writeMethod == null) {
                continue;
            }
            // TODO: the property name must be a valid URI - need to check context for terms?
            String propertyName = getWritableExposedPropertyOrPropertyName(propertyDescriptor);

            Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, propertyDescriptor
                    .getName());

            MethodParameter methodParameter = new MethodParameter(propertyDescriptor.getWriteMethod(), 0);
            ActionInputParameter propertySetterInputParameter = new SpringActionInputParameter(
                    methodParameter, propertyValue);

            properties.put(propertyName, propertySetterInputParameter);
        }

        // write all supported properties
        // TODO we are using the annotatedParameter.parameterName but should use the key of properties here:
        for (ActionInputParameter annotatedParameter : properties.values()) {
            String nextPropertyPathLevel = propertyPath.isEmpty() ? annotatedParameter.getParameterName() :
                    propertyPath + '.' + annotatedParameter.getParameterName();
            Class<?> parameterType = annotatedParameter.getParameterType();
            if (DataType.isSingleValueType(parameterType)) {

                final Object[] possiblePropertyValues = rootParameter.getPossibleValues(allRootParameters);

                if (rootParameter.isIncluded(nextPropertyPathLevel) && !rootParameter.isExcluded
                        (nextPropertyPathLevel)) {
                    writeSupportedProperty(jgen, currentVocab, annotatedParameter,
                            annotatedParameter.getParameterName(), possiblePropertyValues);
                }
                // TODO collections?
                //                        } else if (DataType.isArrayOrCollection(parameterType)) {
                //                            Object[] callValues = rootParameter.getValues();
                //                            int items = callValues.length;
                //                            for (int i = 0; i < items; i++) {
                //                                Object value;
                //                                if (i < callValues.length) {
                //                                    value = callValues[i];
                //                                } else {
                //                                    value = null;
                //                                }
                //                                recurseSupportedProperties(jgen, currentVocab, rootParameter
                // .getParameterType(),
                //                                        allRootParameters, rootParameter, value);
                //                            }
            } else {
                jgen.writeStartObject();
                jgen.writeStringField("hydra:property", annotatedParameter.getParameterName());
                // TODO: is the property required -> for bean props we need the Access annotation to express that

                Expose expose = AnnotationUtils.getAnnotation(parameterType, Expose.class);
                String subClass = null;
                if (expose != null) {
                    subClass = expose.value();
                } else {
                    if (List.class.isAssignableFrom(parameterType)) {
                        Type genericParameterType = annotatedParameter.getGenericParameterType();
                        if (genericParameterType instanceof ParameterizedType) {
                            ParameterizedType parameterizedType = (ParameterizedType) genericParameterType;
                            Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
                            if (actualTypeArguments.length == 1) {
                                Type actualTypeArgument = actualTypeArguments[0];
                                if (actualTypeArgument instanceof Class) {
                                    parameterType = (Class<?>) actualTypeArgument;
                                    subClass = parameterType.getSimpleName();
                                } else if (actualTypeArgument instanceof ParameterizedType) {
                                    ParameterizedType genericItemType = (ParameterizedType) actualTypeArgument;
                                    Type rawType = genericItemType.getRawType();
                                    if (rawType instanceof Class) {
                                        parameterType = (Class<?>) rawType;
                                        subClass = parameterType.getSimpleName();
                                    }
                                }
                            }
                        }
                        if (subClass != null) {
                            String multipleValueProp = getPropertyOrClassNameInVocab(currentVocab,
                                    "multipleValues",
                                    LdContextFactory.HTTP_SCHEMA_ORG,
                                    "schema:");
                            jgen.writeBooleanField(multipleValueProp, true);
                        }
                    }
                }
                if (subClass == null) {
                    subClass = parameterType.getSimpleName();
                }
                jgen.writeObjectFieldStart(getPropertyOrClassNameInVocab(currentVocab, "rangeIncludes",
                        LdContextFactory.HTTP_SCHEMA_ORG, "schema:"));

                jgen.writeStringField(getPropertyOrClassNameInVocab(currentVocab,
                        "subClassOf",
                        "http://www.w3.org/2000/01/rdf-schema#",
                        "rdfs:"), subClass);

                jgen.writeArrayFieldStart("hydra:supportedProperty");
                // TODO let defaultValue be an filled list, if needed
                Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, annotatedParameter
                        .getParameterName());

                recurseSupportedProperties(jgen, currentVocab, parameterType,
                        allRootParameters,
                        rootParameter, propertyValue, nextPropertyPathLevel);
                jgen.writeEndArray();

                jgen.writeEndObject();
                jgen.writeEndObject();
            }
        }

    }

    /**
     * Gets property or class name in the current context, either without prefix if the current vocab is the given
     * vocabulary, or prefixed otherwise.
     *
     * @param currentVocab              to determine the current vocab
     * @param propertyOrClassName       name to contextualize
     * @param vocabulary                to which the given property belongs
     * @param vocabularyPrefixWithColon to use if the current vocab does not match the given vocabulary to which the name belongs, should end
     *                                  with colon
     * @return property name or class name in the currenct context
     */

    private String getPropertyOrClassNameInVocab(@Nullable String currentVocab, String propertyOrClassName, String
            vocabulary, String vocabularyPrefixWithColon) {
        Assert.notNull(vocabulary);
        String ret;
        if (vocabulary.equals(currentVocab)) {
            ret = propertyOrClassName;
        } else {
            ret = vocabularyPrefixWithColon + propertyOrClassName;
        }
        return ret;
    }

    private void writeSupportedProperty(JsonGenerator jgen, String currentVocab,
                                        ActionInputParameter actionInputParameter,
                                        String propertyName, @SuppressWarnings("unused") Object[]
                                                possiblePropertyValues)
            throws IOException {

        jgen.writeStartObject();

        if (actionInputParameter.hasValue() || actionInputParameter.hasInputConstraints()) {
            // jgen.writeArrayFieldStart("@type");
            // jgen.writeString("hydra:SupportedProperty");

            jgen.writeStringField(JsonLdKeywords.AT_TYPE, getPropertyOrClassNameInVocab(currentVocab,
                    "PropertyValueSpecification", LdContextFactory.HTTP_SCHEMA_ORG, "schema:"));

            //jgen.writeEndArray();
        }
        jgen.writeStringField("hydra:property", propertyName);

        writePossiblePropertyValues(jgen, currentVocab, actionInputParameter, possiblePropertyValues);

        jgen.writeEndObject();
    }

    private void writePossiblePropertyValues(JsonGenerator jgen, String currentVocab, ActionInputParameter
            actionInputParameter, @SuppressWarnings("unused") Object[] possiblePropertyValues) throws IOException {
        // Enable the following to list possible values.
        // Problem: how to express individuals only for certain hydra:options
        // not all hydra:options should be taken as uris, sometimes they might be just literals
        // how to make that clear to the client?
        // maybe we must write them out for options
//        if (possiblePropertyValues.length > 0) {
//            jgen.writeArrayFieldStart("hydra:option");
//
//            for (Object possibleValue : possiblePropertyValues) {
//                // TODO: apply "hydra:option" : { "@type": "@vocab"} to context for enums
//                writeScalarValue(jgen, possibleValue, rootParameter.getParameterType());
//            }
//            jgen.writeEndArray();
//        }

        if (actionInputParameter.isArrayOrCollection()) {
            jgen.writeBooleanField(getPropertyOrClassNameInVocab(currentVocab, "multipleValues",
                    LdContextFactory.HTTP_SCHEMA_ORG, "schema:"), true);
        }

        //  valueRequired (hard to say, using @Access on Event is for all update requests - or make
        //     specific request beans for different
        //     purposes rather than always passing an instance of e.g. Event?)
        //       -> update is a different use case than create - or maybe have an @Requires("eventStatus")
        //          annotation alongside requestBody to tell which attributes are required or writable, and use
        // Requires over
        //          bean structure, where ctor with least length of args is required and setters are supported
        //          but optional? The bean structure does say what is writable for updates, but not what is required
        // for creation. Right now setters are supportedProperties. For creation we would have to add constructor
        // arguments as supportedProperties.
        //  (/) defaultValue (pre-filled value, e.g. list of selected items for option)
        //  valueName (for iri templates only)
        //  (/) readonlyValue (true for final public field or absence of setter, send fixed value like hidden field?)
        // -> use hydra:readable, hydra:writable
        //  (/) multipleValues
        //  (/) valueMinLength
        //  (/) valueMaxLength
        //  (/) valuePattern
        //  minValue (DateTime support)
        //  maxValue (DateTime support)
        //  (/) stepValue
        final Map<String, Object> inputConstraints = actionInputParameter.getInputConstraints();

        if (actionInputParameter.hasValue()) {
            if (actionInputParameter.isArrayOrCollection()) {
                Object[] callValues = actionInputParameter.getValues();
                Class<?> componentType = callValues.getClass()
                        .getComponentType();
                // only write defaultValue for array of scalars
                if (DataType.isSingleValueType(componentType)) {
                    jgen.writeFieldName(getPropertyOrClassNameInVocab(currentVocab, "defaultValue",
                            LdContextFactory.HTTP_SCHEMA_ORG, "schema:"));
                    jgen.writeStartArray();
                    for (Object callValue : callValues) {
                        writeScalarValue(jgen, callValue, componentType);
                    }
                    jgen.writeEndArray();
                }
            } else {
                jgen.writeFieldName(getPropertyOrClassNameInVocab(currentVocab, "defaultValue",
                        LdContextFactory.HTTP_SCHEMA_ORG, "schema:"));

                writeScalarValue(jgen, actionInputParameter.getValue(), actionInputParameter
                        .getParameterType());
            }
        }

        if (!inputConstraints.isEmpty()) {
            final List<String> keysToAppendValue = Arrays.asList(Input.MAX, Input.MIN,
                    Input.STEP);
            for (String keyToAppendValue : keysToAppendValue) {
                final Object constraint = inputConstraints.get(keyToAppendValue);
                if (constraint != null) {
                    jgen.writeFieldName(getPropertyOrClassNameInVocab(currentVocab, keyToAppendValue + "Value",
                            LdContextFactory.HTTP_SCHEMA_ORG, "schema:"));
                    jgen.writeNumber(constraint
                            .toString());
                }
            }

            final List<String> keysToPrependValue = Arrays.asList(Input.MAX_LENGTH,
                    Input.MIN_LENGTH, Input.PATTERN);
            for (String keyToPrependValue : keysToPrependValue) {
                final Object constraint = inputConstraints.get(keyToPrependValue);
                if (constraint != null) {
                    jgen.writeFieldName(getPropertyOrClassNameInVocab(currentVocab, "value" + StringUtils.capitalize
                                    (keyToPrependValue),
                            LdContextFactory.HTTP_SCHEMA_ORG, "schema:"));
                    if (Input.PATTERN.equals(keyToPrependValue)) {
                        jgen.writeString(constraint.toString());
                    } else {
                        jgen.writeNumber(constraint
                                .toString());
                    }
                }
            }
        }
    }

    private void writeScalarValue(JsonGenerator jgen, Object possibleValue,
                                  Class<?> valueType) throws IOException {
        if (Number.class.isAssignableFrom(valueType)) {
            jgen.writeNumber(possibleValue.toString());
        } else if (Boolean.class.isAssignableFrom(valueType)) {
            jgen.writeBoolean((Boolean) possibleValue);
        } else if (Enum.class.isAssignableFrom(valueType)) {
            jgen.writeString(((Enum) possibleValue).name());
        } else {
            jgen.writeString(possibleValue.toString());
        }
    }

//    private boolean isSelected(Object possibleValue, ActionInputParameter rootParameter) {
//        boolean ret;
//        if (rootParameter.isArrayOrCollection()) {
//            ret = ArrayUtils.contains(rootParameter.getValues(), possibleValue);
//        } else {
//            final Object callValue = rootParameter.getValue();
//            ret = (callValue == null ? false :
//                    callValue.equals(possibleValue));
//        }
//        return ret;
//    }

//    private void writePropertyValueSpecifications(JsonGenerator jgen,
//                                                  ActionDescriptor allRootParameters) throws IOException {
//        // TODO use input constraints
//        for (String pathVariableName : allRootParameters.getPathVariableNames()) {
//            jgen.writeStringField(pathVariableName + "-input", "required");
//        }
//        for (String requestParamName : allRootParameters.getRequestParamNames()) {
//            // TODO could be a list -> tell the client using select options, but what about a list
//            // of free length, such as ids?
//            jgen.writeStringField(requestParamName + "-input", "required");
//        }
//    }

//    private void writeSimpleTarget(JsonGenerator jgen, Link action, Affordance affordance) throws IOException {
//        jgen.writeStringField("target", action.getHref());
//    }
//
//    private void writeEntryPointTarget(JsonGenerator jgen, Link action, Affordance affordance) throws IOException {
//        jgen.writeObjectFieldStart("target");
//        jgen.writeStringField("@type", "EntryPoint");
//        jgen.writeStringField("urlTemplate", action.getHref());
//        List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors();
//        if (actionDescriptors != null && actionDescriptors.getHttpMethod() != null) {
//            jgen.writeStringField("httpMethod", actionDescriptors.getHttpMethod().name());
//        }
//        // TODO encodingType, contentType, application
//        jgen.writeEndObject();
//    }

    private void writeHydraVariableMapping(JsonGenerator jgen, @Nullable ActionDescriptor annotatedParameters,
                                           Collection<String> variableNames) throws IOException {
        if (annotatedParameters != null) {
            for (String variableName : variableNames) {
                // TODO: find also @Input
                ActionInputParameter annotatedParameter = annotatedParameters.getActionInputParameter(variableName);
                // TODO access @Input parameter, too
                // only unsatisfied parameters become hydra variables
                if (annotatedParameter != null && annotatedParameter.getValue() == null) {
                    jgen.writeStartObject();
                    jgen.writeStringField("@type", "hydra:IriTemplateMapping");
                    jgen.writeStringField("hydra:variable", annotatedParameter.getParameterName());
                    jgen.writeBooleanField("hydra:required",
                            annotatedParameter
                                    .isRequired());
                    jgen.writeStringField("hydra:property",
                            getExposedPropertyOrParamName(annotatedParameter));
                    jgen.writeEndObject();
                }
            }
        }
    }

    /**
     * Gets exposed property or parameter name.
     *
     * @param inputParameter for exposure
     * @return property name
     */
    private String getExposedPropertyOrParamName(ActionInputParameter inputParameter) {
        final Expose expose = inputParameter.getAnnotation(Expose.class);
        String property;
        if (expose != null) {
            property = expose.value();
        } else {
            property = inputParameter.getParameterName();
        }
        return property;
    }

    /**
     * Gets exposed property or parameter name for properties with an appropriate setter (=write) method.
     *
     * @param inputParameter for exposure
     * @return property name
     */
    private String getWritableExposedPropertyOrPropertyName(PropertyDescriptor inputParameter) {

        final Method writeMethod = inputParameter.getWriteMethod();
        final Expose expose = writeMethod
                .getAnnotation(Expose.class);
        String propertyName;
        if (expose != null) {
            propertyName = expose.value();
        } else {
            propertyName = inputParameter.getName();
        }
        return propertyName;
    }

    @Override
    public boolean isUnwrappingSerializer() {
        return true;
    }
}


/*
 * Copyright (c) 2015. Escalon System-Entwicklung, Dietrich Schulten
 *
 * 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 de.escalon.hypermedia.spring.uber;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.node.ObjectNode;
import de.escalon.hypermedia.PropertyUtils;
import de.escalon.hypermedia.action.Type;
import de.escalon.hypermedia.affordance.*;
import de.escalon.hypermedia.spring.SpringActionDescriptor;
import de.escalon.hypermedia.spring.SpringActionInputParameter;
import org.springframework.core.MethodParameter;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.Resources;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.RequestMethod;

import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.*;
import java.util.Map.Entry;

public class UberUtils {

    private UberUtils() {

    }

    static final Set<String> FILTER_RESOURCE_SUPPORT = new HashSet<String>(Arrays.asList("class", "links", "id"));
    static final String MODEL_FORMAT = "%s={%s}";

    /**
     * Recursively converts object to nodes of uber data.
     *
     * @param objectNode
     *         to append to
     * @param object
     *         to convert
     */
    public static void toUberData(AbstractUberNode objectNode, Object object) {
        Set<String> filtered = FILTER_RESOURCE_SUPPORT;
        if (object == null) {
            return;
        }

        try {
            // TODO: move all returns to else branch of property descriptor handling
            if (object instanceof Resource) {
                Resource<?> resource = (Resource<?>) object;
                objectNode.addLinks(resource.getLinks());
                toUberData(objectNode, resource.getContent());
                return;
            } else if (object instanceof Resources) {
                Resources<?> resources = (Resources<?>) object;

                // TODO set name using EVO see HypermediaSupportBeanDefinitionRegistrar

                objectNode.addLinks(resources.getLinks());

                Collection<?> content = resources.getContent();
                toUberData(objectNode, content);
                return;
            } else if (object instanceof ResourceSupport) {
                ResourceSupport resource = (ResourceSupport) object;

                objectNode.addLinks(resource.getLinks());

                // wrap object attributes below to avoid endless loop

            } else if (object instanceof Collection) {
                Collection<?> collection = (Collection<?>) object;
                for (Object item : collection) {
                    // TODO name must be repeated for each collection item
                    UberNode itemNode = new UberNode();
                    objectNode.addData(itemNode);
                    toUberData(itemNode, item);
                }
                return;
            }
            if (object instanceof Map) {
                Map<?, ?> map = (Map<?, ?>) object;
                for (Entry<?, ?> entry : map.entrySet()) {
                    String key = entry.getKey()
                            .toString();
                    Object content = entry.getValue();
                    Object value = getContentAsScalarValue(content);
                    UberNode entryNode = new UberNode();
                    objectNode.addData(entryNode);
                    entryNode.setName(key);
                    if (value != null) {
                        entryNode.setValue(value);
                    } else {
                        toUberData(entryNode, content);
                    }
                }
            } else {
                Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors(object);
                for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) {
                    String name = propertyDescriptor.getName();
                    if (filtered.contains(name)) {
                        continue;
                    }
                    UberNode propertyNode = new UberNode();
                    Object content = propertyDescriptor.getReadMethod()
                            .invoke(object);

                    if (isEmptyCollectionOrMap(content, propertyDescriptor.getPropertyType())) {
                        continue;
                    }

                    Object value = getContentAsScalarValue(content);
                    propertyNode.setName(name);
                    objectNode.addData(propertyNode);
                    if (value != null) {
                        // for each scalar property of a simple bean, add valuepair nodes to data
                        propertyNode.setValue(value);
                    } else {
                        toUberData(propertyNode, content);
                    }
                }

                Field[] fields = object.getClass()
                        .getFields();
                for (Field field : fields) {
                    String name = field.getName();
                    if (!propertyDescriptors.containsKey(name)) {
                        Object content = field.get(object);
                        Class<?> type = field.getType();
                        if (isEmptyCollectionOrMap(content, type)) {
                            continue;
                        }
                        UberNode propertyNode = new UberNode();

                        Object value = getContentAsScalarValue(content);
                        propertyNode.setName(name);
                        objectNode.addData(propertyNode);
                        if (value != null) {
                            // for each scalar property of a simple bean, add valuepair nodes to data
                            propertyNode.setValue(value);
                        } else {
                            toUberData(propertyNode, content);
                        }

                    }
                }
            }
        } catch (Exception ex) {
            throw new RuntimeException("failed to transform object " + object, ex);
        }
    }

    private static boolean isEmptyCollectionOrMap(Object content, Class<?> type) {
        if (Collection.class.isAssignableFrom(type)) {
            if (content == null) {
                return true;
            } else {
                if (((List) content).isEmpty()) {
                    return true;
                }
            }
        } else if (Map.class.isAssignableFrom(type)) {
            if (content == null) {
                return true;
            } else {
                if (((List) content).isEmpty()) {
                    return true;
                }
            }
        }
        return false;
    }

    private static Object getContentAsScalarValue(Object content) {
        final Object value;
        if (content == null) {
            value = UberNode.NULL_VALUE;
        } else if (DataType.isSingleValueType(content.getClass())) {
            value = content.toString();
        } else {
            value = null;
        }
        return value;
    }

    /**
     * Converts single link to uber node.
     *
     * @param href
     *         to use
     * @param actionDescriptor
     *         to use for action and model, never null
     * @param rels
     *         of the link
     * @return uber link
     */
    public static UberNode toUberLink(String href, ActionDescriptor actionDescriptor, String... rels) {
        return toUberLink(href, actionDescriptor, Arrays.asList(rels));
    }

    /**
     * Converts single link to uber node.
     *
     * @param href
     *         to use
     * @param actionDescriptor
     *         to use for action and model, never null
     * @param rels
     *         of the link
     * @return uber link
     */
    public static UberNode toUberLink(String href, ActionDescriptor actionDescriptor, List<String> rels) {
        Assert.notNull(actionDescriptor, "actionDescriptor must not be null");
        UberNode uberLink = new UberNode();
        uberLink.setRel(rels);
        PartialUriTemplateComponents partialUriTemplateComponents = new PartialUriTemplate(href).expand(Collections
                .<String, Object>emptyMap());
        uberLink.setUrl(partialUriTemplateComponents.toString());
        uberLink.setTemplated(partialUriTemplateComponents.hasVariables() ? Boolean.TRUE : null);
        uberLink.setModel(getModelProperty(href, actionDescriptor));
        if (actionDescriptor != null) {
            RequestMethod requestMethod = RequestMethod.valueOf(actionDescriptor.getHttpMethod());
            uberLink.setAction(UberAction.forRequestMethod(requestMethod));
        }
        return uberLink;
    }

    private static String getModelProperty(String href, ActionDescriptor actionDescriptor) {

        RequestMethod httpMethod = RequestMethod.valueOf(actionDescriptor.getHttpMethod());
        StringBuffer model = new StringBuffer();

        switch (httpMethod) {
            case POST:
            case PUT:
            case PATCH: {
                List<UberField> uberFields = new ArrayList<UberField>();
                recurseBeanCreationParams(uberFields, actionDescriptor.getRequestBody()
                        .getParameterType(), actionDescriptor, actionDescriptor.getRequestBody(), actionDescriptor
                        .getRequestBody()
                        .getValue(), "", Collections.<String>emptySet());
                for (UberField uberField : uberFields) {
                    if (model.length() > 0) {
                        model.append("&");
                    }
                    model.append(String.format(MODEL_FORMAT, uberField.getName(), uberField.getName()));
                }
                break;
            }
            default:

        }
        return model.length() == 0 ? null : model.toString();
    }

//    private List<SirenAction> toUberActions(List<Link> links) {
//        List<SirenAction> ret = new ArrayList<SirenAction>();
//        for (Link link : links) {
//            if (link instanceof Affordance) {
//                Affordance affordance = (Affordance) link;
//                List<ActionDescriptor> actionDescriptors = affordance.getActionDescriptors();
//                for (ActionDescriptor actionDescriptor : actionDescriptors) {
//                    List<SirenField> fields = toUberFields(actionDescriptor);
//                    // TODO integrate getActions and this method so we do not need this check:
//                    // only templated affordances or non-get affordances are actions
//                    if (!"GET".equals(actionDescriptor.getHttpMethod()) || affordance.isTemplated()) {
//                        String href;
//                        if (affordance.isTemplated()) {
//                            href = affordance.getUriTemplateComponents()
//                                    .getBaseUri();
//                        } else {
//                            href = affordance.getHref();
//                        }
//
//
//                        SirenAction sirenAction = new SirenAction(null, actionDescriptor.getActionName(), null,
//                                actionDescriptor.getHttpMethod(), href, requestMediaType, fields);
//                        ret.add(sirenAction);
//                    }
//                }
//            } else if (link.isTemplated()) {
//                List<SirenField> fields = new ArrayList<SirenField>();
//                List<TemplateVariable> variables = link.getVariables();
//                boolean queryOnly = false;
//                for (TemplateVariable variable : variables) {
//                    queryOnly = isQueryParam(variable);
//                    if (!queryOnly) {
//                        break;
//                    }
//                    fields.add(new SirenField(variable.getName(), "text", (String) null, variable.getDescription(),
//                            null));
//                }
//                // no support for non-query fields in siren
//                if (queryOnly) {
//                    String baseUri = new UriTemplate(link.getHref()).expand()
//                            .toASCIIString();
//                    SirenAction sirenAction = new SirenAction(null, null, null, "GET",
//                            baseUri, null, fields);
//                    ret.add(sirenAction);
//                }
//            }
//        }
//        return ret;
//    }

//    private List<SirenField> toUberFields(ActionDescriptor actionDescriptor) {
//        List<SirenField> ret = new ArrayList<SirenField>();
//        if (actionDescriptor.hasRequestBody()) {
//            recurseBeanCreationParams(ret, actionDescriptor.getRequestBody()
//                    .getParameterType(), actionDescriptor, actionDescriptor.getRequestBody(), actionDescriptor
//                    .getRequestBody()
//                    .getValue(), "", Collections.<String>emptySet());
//        }
////        } else {
////            Collection<String> paramNames = actionDescriptor.getRequestParamNames();
////            for (String paramName : paramNames) {
////                ActionInputParameter inputParameter = actionDescriptor.getActionInputParameter(paramName);
////                Object[] possibleValues = inputParameter.getPossibleValues(actionDescriptor);
////
////                ret.add(createSirenField(paramName, inputParameter.getValueFormatted(), inputParameter,
////                        possibleValues));
////            }
////        }
//        return ret;
//    }
//

    /**
     * Renders input fields for bean properties of bean to add or update or patch.
     *
     * @param uberFields
     *         to add to
     * @param beanType
     *         to render
     * @param annotatedParameters
     *         which describes the method
     * @param annotatedParameter
     *         which requires the bean
     * @param currentCallValue
     *         sample call value
     */
    private static void recurseBeanCreationParams(List<UberField> uberFields, Class<?> beanType,
                                                  ActionDescriptor annotatedParameters,
                                                  ActionInputParameter annotatedParameter, Object currentCallValue,
                                                  String parentParamName, Set<String> knownFields) {
        // TODO collection, map and object node creation are only describable by an annotation, not via type reflection
        if (ObjectNode.class.isAssignableFrom(beanType) || Map.class.isAssignableFrom(beanType)
                || Collection.class.isAssignableFrom(beanType) || beanType.isArray()) {
            return; // use @Input(include) to list parameter names, at least? Or mix with hdiv's form builder?
        }
        try {
            Constructor[] constructors = beanType.getConstructors();
            // find default ctor
            Constructor constructor = PropertyUtils.findDefaultCtor(constructors);
            // find ctor with JsonCreator ann
            if (constructor == null) {
                constructor = PropertyUtils.findJsonCreator(constructors, JsonCreator.class);
            }
            Assert.notNull(constructor, "no default constructor or JsonCreator found for type " + beanType
                    .getName());
            int parameterCount = constructor.getParameterTypes().length;

            if (parameterCount > 0) {
                Annotation[][] annotationsOnParameters = constructor.getParameterAnnotations();

                Class[] parameters = constructor.getParameterTypes();
                int paramIndex = 0;
                for (Annotation[] annotationsOnParameter : annotationsOnParameters) {
                    for (Annotation annotation : annotationsOnParameter) {
                        if (JsonProperty.class == annotation.annotationType()) {
                            JsonProperty jsonProperty = (JsonProperty) annotation;

                            // TODO use required attribute of JsonProperty for required fields
                            String paramName = jsonProperty.value();
                            Class parameterType = parameters[paramIndex];
                            Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue,
                                    paramName);
                            MethodParameter methodParameter = new MethodParameter(constructor, paramIndex);

                            addUberFieldsForMethodParameter(uberFields, methodParameter, annotatedParameter,
                                    annotatedParameters,
                                    parentParamName, paramName, parameterType, propertyValue,
                                    knownFields);
                            paramIndex++; // increase for each @JsonProperty
                        }
                    }
                }
                Assert.isTrue(parameters.length == paramIndex,
                        "not all constructor arguments of @JsonCreator " + constructor.getName() +
                                " are annotated with @JsonProperty");
            }

            Set<String> knownConstructorFields = new HashSet<String>(uberFields.size());
            for (UberField sirenField : uberFields) {
                knownConstructorFields.add(sirenField.getName());
            }

            // TODO support Option provider by other method args?
            Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors(beanType);

            // add input field for every setter
            for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) {
                final Method writeMethod = propertyDescriptor.getWriteMethod();
                String propertyName = propertyDescriptor.getName();

                if (writeMethod == null || knownFields.contains(parentParamName + propertyName)) {
                    continue;
                }
                final Class<?> propertyType = propertyDescriptor.getPropertyType();

                Object propertyValue = PropertyUtils.getPropertyOrFieldValue(currentCallValue, propertyName);
                MethodParameter methodParameter = new MethodParameter(propertyDescriptor.getWriteMethod(), 0);

                addUberFieldsForMethodParameter(uberFields, methodParameter, annotatedParameter,
                        annotatedParameters,
                        parentParamName, propertyName, propertyType, propertyValue, knownConstructorFields);
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to write input fields for constructor", e);
        }
    }

    public static List<ActionDescriptor> getActionDescriptors(Link link) {
        List<ActionDescriptor> actionDescriptors;
        if (link instanceof Affordance) {
            actionDescriptors = ((Affordance) link).getActionDescriptors();
        } else {
            SpringActionDescriptor actionDescriptor = new SpringActionDescriptor("get", RequestMethod.GET
                    .name());
            PartialUriTemplate partialUriTemplate = new PartialUriTemplate(link.getHref());
            PartialUriTemplateComponents parts = partialUriTemplate.asComponents();
            actionDescriptors = Arrays.asList((ActionDescriptor) actionDescriptor);
        }
        return actionDescriptors;
    }

    public static List<String> getRels(Link link) {
        List<String> rels;
        if (link instanceof Affordance) {
            rels = ((Affordance) link).getRels();
        } else {
            rels = Arrays.asList(link.getRel());
        }
        return rels;
    }

    private static void addUberFieldsForMethodParameter(List<UberField> fields, MethodParameter
            methodParameter, ActionInputParameter annotatedParameter, ActionDescriptor annotatedParameters, String
                                                                parentParamName, String paramName, Class
                                                                parameterType, Object propertyValue, Set<String>
                                                                knownFields) {
        if (DataType.isSingleValueType(parameterType)
                || DataType.isArrayOrCollection(parameterType)) {

            if (annotatedParameter.isIncluded(paramName) && !knownFields.contains(parentParamName + paramName)) {

                ActionInputParameter constructorParamInputParameter =
                        new SpringActionInputParameter(methodParameter, propertyValue);

                final Object[] possibleValues =
                        annotatedParameter.getPossibleValues(methodParameter, annotatedParameters);

                // dot-separated property path as field name
                UberField field = createUberField(parentParamName + paramName,
                        propertyValue, constructorParamInputParameter, possibleValues);
                fields.add(field);
            }
        } else {
            Object callValueBean;
            if (propertyValue instanceof Resource) {
                callValueBean = ((Resource) propertyValue).getContent();
            } else {
                callValueBean = propertyValue;
            }
            recurseBeanCreationParams(fields, parameterType, annotatedParameters,
                    annotatedParameter,
                    callValueBean, paramName + ".", knownFields);
        }
    }

    private static UberField createUberField(String paramName, Object propertyValue,
                                             ActionInputParameter inputParameter, Object[] possibleValues) {
        UberField field;
//        if (possibleValues.length == 0) {
        String propertyValueAsString = propertyValue == null ? null : propertyValue
                .toString();
        Type htmlInputFieldType = inputParameter.getHtmlInputFieldType();
        // TODO: null -> array or bean parameter without possible values
        String type = htmlInputFieldType == null ? "text" :
                htmlInputFieldType
                        .name()
                        .toLowerCase();
        field = new UberField(paramName,
                propertyValueAsString);
//        } else {
//            List<SirenFieldValue> sirenPossibleValues = new ArrayList<SirenFieldValue>();
//            String type;
//            if (inputParameter.isArrayOrCollection()) {
//                type = "checkbox";
//                for (Object possibleValue : possibleValues) {
//                    boolean selected = ObjectUtils.containsElement(
//                            inputParameter.getValues(),
//                            possibleValue);
//                    // TODO have more useful value title
//                    sirenPossibleValues.add(new SirenFieldValue(possibleValue.toString(), possibleValue, selected));
//                }
//            } else {
//                type = "radio";
//                for (Object possibleValue : possibleValues) {
//                    boolean selected = possibleValue.equals(propertyValue);
//                    sirenPossibleValues.add(new SirenFieldValue(possibleValue.toString(), possibleValue, selected));
//                }
//            }
//            field = new UberField(paramName,
//                    sirenPossibleValues);
//    }

        return field;
    }

}


/*
 * Copyright (c) 2014. Escalon System-Entwicklung, Dietrich Schulten
 *
 * 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 de.escalon.hypermedia.spring;

import de.escalon.hypermedia.PropertyUtils;
import de.escalon.hypermedia.action.Action;
import de.escalon.hypermedia.action.Cardinality;
import de.escalon.hypermedia.action.Input;
import de.escalon.hypermedia.action.ResourceHandler;
import de.escalon.hypermedia.affordance.ActionDescriptor;
import de.escalon.hypermedia.affordance.ActionInputParameter;
import de.escalon.hypermedia.affordance.DataType;
import de.escalon.hypermedia.affordance.PartialUriTemplate;
import org.jetbrains.annotations.NotNull;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.hateoas.MethodLinkBuilderFactory;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.core.AnnotationMappingDiscoverer;
import org.springframework.hateoas.core.DummyInvocationUtils;
import org.springframework.hateoas.core.MappingDiscoverer;
import org.springframework.hateoas.core.MethodParameters;
import org.springframework.hateoas.mvc.ControllerLinkBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;

import java.beans.PropertyDescriptor;
import java.lang.annotation.Annotation;
import java.lang.reflect.*;
import java.util.*;

/**
 * Factory for {@link AffordanceBuilder}s in a Spring MVC rest service. Normally one should use the static methods of
 * AffordanceBuilder to get an AffordanceBuilder. Created by dschulten on 03.10.2014.
 */
public class AffordanceBuilderFactory implements MethodLinkBuilderFactory<AffordanceBuilder> {

    private static final MappingDiscoverer MAPPING_DISCOVERER = new AnnotationMappingDiscoverer(RequestMapping.class);

    @Override
    public AffordanceBuilder linkTo(Method method, Object... parameters) {
        return linkTo(method.getDeclaringClass(), method, parameters);
    }

    @Override
    public AffordanceBuilder linkTo(Class<?> controller, Method method, Object... parameters) {

        String pathMapping = MAPPING_DISCOVERER.getMapping(controller, method);

        Map<String, String> requestParamNames = getRequestParamNames(method);
        Map<String, String> inputBeanParamNames = getInputBeanParamNames(method);

        String query = join(requestParamNames, inputBeanParamNames);
        // explicitly write out params where variable name and param are different, rest as continuation
        String mapping = StringUtils.isEmpty(query) ? pathMapping : pathMapping + query;

        PartialUriTemplate partialUriTemplate = new PartialUriTemplate(AffordanceBuilder.getBuilder()
                .build()
                .toString() + mapping);

        Map<String, Object> values = new HashMap<String, Object>();
        Iterator<String> variableNames = partialUriTemplate.getVariableNames()
                .iterator();
        // there may be more or less mapping variables than arguments
        for (Object parameter : parameters) {
            if (!variableNames.hasNext()) {
                break;
            }
            values.put(variableNames.next(), parameter);
        }
        // there may be more or less mapping variables than arguments
        // do not use input bean param names here
        for (Object argument : parameters) {
            if (!variableNames.hasNext()) {
                break;
            }
            String variableName = variableNames.next();
            if (!inputBeanParamNames.containsKey(variableName)) {
                values.put(variableName, argument);
            }
        }

        ActionDescriptor actionDescriptor = createActionDescriptor(method, values, parameters);

        return new AffordanceBuilder(partialUriTemplate.expand(values), Collections.singletonList(actionDescriptor));
    }

    private String join(Map<String, String>... params) {
        StringBuilder levelFourQuery = new StringBuilder();
        for (Map<String, String> paramMap : params) {

            for (Map.Entry<String, String> parameter : paramMap.entrySet()) {
                if (levelFourQuery.length() > 0) {
                    levelFourQuery.append(",");
                }
                levelFourQuery.append(parameter.getValue());
            }
        }
        StringBuilder ret = new StringBuilder();
        if (levelFourQuery.length() > 0) {
            ret.append("{?")
                    .append(levelFourQuery)
                    .append("}");
        }
        return ret.toString();
    }

    @Override
    public AffordanceBuilder linkTo(Class<?> target) {
        return linkTo(target, new Object[0]);
    }

    @Override
    public AffordanceBuilder linkTo(Class<?> controller, Object... parameters) {
        Assert.notNull(controller);

        String mapping = MAPPING_DISCOVERER.getMapping(controller);

        PartialUriTemplate partialUriTemplate = new PartialUriTemplate(mapping == null ? "/" : mapping);

        Map<String, Object> values = new HashMap<String, Object>();
        Iterator<String> names = partialUriTemplate.getVariableNames()
                .iterator();
        // there may be more or less mapping variables than arguments
        for (Object parameter : parameters) {
            if (!names.hasNext()) {
                break;
            }
            values.put(names.next(), parameter);
        }
        return new AffordanceBuilder().slash(partialUriTemplate.expand(values));
    }

    @Override
    public AffordanceBuilder linkTo(Class<?> controller, Map<String, ?> parameters) {
        String mapping = MAPPING_DISCOVERER.getMapping(controller);
        PartialUriTemplate partialUriTemplate = new PartialUriTemplate(mapping == null ? "/" : mapping);
        return new AffordanceBuilder().slash(partialUriTemplate.expand(parameters));
    }

    @Override
    public AffordanceBuilder linkTo(Object invocationValue) {

        Assert.isInstanceOf(DummyInvocationUtils.LastInvocationAware.class, invocationValue);
        DummyInvocationUtils.LastInvocationAware invocations = (DummyInvocationUtils.LastInvocationAware)
                invocationValue;

        DummyInvocationUtils.MethodInvocation invocation = invocations.getLastInvocation();
        Method invokedMethod = invocation.getMethod();

        String pathMapping = MAPPING_DISCOVERER.getMapping(invokedMethod);
        Iterator<Object> classMappingParameters = invocations.getObjectParameters();

        Map<String, String> requestParamNames = getRequestParamNames(invokedMethod);
        Map<String, String> inputBeanParamNames = getInputBeanParamNames(invokedMethod);

        String query = join(requestParamNames, inputBeanParamNames);
        String mapping = StringUtils.isEmpty(query) ? pathMapping : pathMapping + query;

        PartialUriTemplate partialUriTemplate = new PartialUriTemplate(AffordanceBuilder.getBuilder()
                .build()
                .toString() + mapping);

        Map<String, Object> values = new HashMap<String, Object>();
        Iterator<String> variableNames = partialUriTemplate.getVariableNames()
                .iterator();
        while (classMappingParameters.hasNext()) {
            values.put(variableNames.next(), classMappingParameters.next());
        }

        // there may be more or less mapping variables than arguments
        // do not use input bean param names here
        for (Object argument : invocation.getArguments()) {
            if (!variableNames.hasNext()) {
                break;
            }
            String variableName = variableNames.next();
            if (!inputBeanParamNames.containsKey(variableName)) {
                values.put(variableName, argument);
            }
        }
        ActionDescriptor actionDescriptor = createActionDescriptor(
                invocation.getMethod(), values, invocation.getArguments());

        return new AffordanceBuilder(partialUriTemplate.expand(values), Collections.singletonList(actionDescriptor));
    }

    private Map<String, String> getInputBeanParamNames(Method invokedMethod) {
        MethodParameters parameters = new MethodParameters(invokedMethod);

        final List<MethodParameter> inputParams = parameters.getParametersWith(Input.class);

        Map<String, String> ret = new LinkedHashMap<String, String>(inputParams.size());
        for (MethodParameter inputParam : inputParams) {
            Class<?> parameterType = inputParam.getParameterType();
            // only use @Input param which is a bean or map and has no other annotations
            // can't use Spring RequestParam etc. to avoid Spring MVC dependency
            if (inputParam.getParameterAnnotations().length == 1 &&
                    !(DataType.isSingleValueType(parameterType) || DataType.isArrayOrCollection(parameterType))) {
                Input inputAnnotation = inputParam.getParameterAnnotation(Input.class);

                Set<String> explicitlyIncludedParams = new LinkedHashSet<String>(inputParams.size());

                Collections.addAll(explicitlyIncludedParams, inputAnnotation.include());
                Collections.addAll(explicitlyIncludedParams, inputAnnotation.hidden());
                Collections.addAll(explicitlyIncludedParams, inputAnnotation.readOnly());

                if (Map.class.isAssignableFrom(parameterType)) {
                    for (String explicitlyIncludedParam : explicitlyIncludedParams) {
                        ret.put(explicitlyIncludedParam, explicitlyIncludedParam);
                    }
                } else {
                    Set<String> inputBeanPropertyNames = getWritablePropertyNames(parameterType);

                    if (explicitlyIncludedParams.isEmpty()) {
                        for (String inputBeanPropertyName : inputBeanPropertyNames) {
                            ret.put(inputBeanPropertyName, inputBeanPropertyName);
                        }
                    } else {
                        for (String explicitlyIncludedParam : explicitlyIncludedParams) {
                            assertInputAnnotationConsistency(inputParam, inputBeanPropertyNames,
                                    explicitlyIncludedParam, "includes");
                            // TODO: have different Qbe property name and variable name?
                            ret.put(explicitlyIncludedParam, explicitlyIncludedParam);
                        }
                    }
                    String[] excludedParams = inputAnnotation.exclude();
                    for (String excludedParam : excludedParams) {
                        assertInputAnnotationConsistency(inputParam, inputBeanPropertyNames,
                                excludedParam, "excludes");
                        ret.remove(excludedParam);
                    }
                }
                break;
            }
        }
        return ret;
    }

    @NotNull
    private Set<String> getWritablePropertyNames(Class<?> parameterType) {
        Set<String> inputBeanPropertyNames = new LinkedHashSet<String>();
        Map<String, PropertyDescriptor> propertyDescriptors = PropertyUtils.getPropertyDescriptors
                (parameterType);
        for (PropertyDescriptor propertyDescriptor : propertyDescriptors.values()) {
            if (propertyDescriptor.getWriteMethod() != null) {
                inputBeanPropertyNames.add(propertyDescriptor.getName());
            }
        }
        return inputBeanPropertyNames;
    }

    private void assertInputAnnotationConsistency(MethodParameter inputParam, Set<String> propertiesToCheckAgainst,
                                                  String propertyToCheck, String argumentKind) {
        if (!propertiesToCheckAgainst.contains(propertyToCheck)) {
            throw new IllegalStateException("@Include " +
                    "annotation on parameter '" + inputParam
                    .getParameterName() + "' of method '" + inputParam.getMethod()
                    .toGenericString() +
                    "' " + argumentKind + " property '" +
                    propertyToCheck + "' but there is no such property on " + inputParam
                    .getParameterType()
                    .getName());
        }
    }

    private Map<String, String> getRequestParamNames(Method invokedMethod) {
        MethodParameters parameters = new MethodParameters(invokedMethod);
        final List<MethodParameter> requestParams = parameters.getParametersWith(RequestParam.class);
        Map<String, String> params = new LinkedHashMap<String, String>(requestParams.size());
        for (MethodParameter requestParam : requestParams) {
            RequestParam requestParamAnnotation = requestParam.getParameterAnnotation(RequestParam.class);
            params.put(requestParam.getParameterName(), requestParamAnnotation.value()
                    .isEmpty() ? requestParam.getParameterName() : requestParamAnnotation.value());
        }

        return params;
    }

    private ActionDescriptor createActionDescriptor(Method invokedMethod,
                                                    Map<String, Object> values, Object[] arguments) {
        RequestMethod httpMethod = getHttpMethod(invokedMethod);
        Type genericReturnType = invokedMethod.getGenericReturnType();

        SpringActionDescriptor actionDescriptor =
                new SpringActionDescriptor(invokedMethod.getName(), httpMethod.name());

        actionDescriptor.setCardinality(getCardinality(invokedMethod, httpMethod, genericReturnType));

        final Action actionAnnotation = AnnotationUtils.getAnnotation(invokedMethod, Action.class);
        if (actionAnnotation != null) {
            actionDescriptor.setSemanticActionType(actionAnnotation.value());
        }

        Map<String, ActionInputParameter> requestBodyMap = getActionInputParameters(RequestBody.class, invokedMethod,
                arguments);
        Assert.state(requestBodyMap.size() < 2, "found more than one request body on " + invokedMethod.getName());
        for (ActionInputParameter value : requestBodyMap.values()) {
            actionDescriptor.setRequestBody(value);
        }

        // the action descriptor needs to know the param type, value and name
        Map<String, ActionInputParameter> requestParamMap =
                getActionInputParameters(RequestParam.class, invokedMethod, arguments);
        for (Map.Entry<String, ActionInputParameter> entry : requestParamMap.entrySet()) {
            ActionInputParameter value = entry.getValue();
            if (value != null) {
                final String key = entry.getKey();
                actionDescriptor.addRequestParam(key, value);
                if (!value.isRequestBody()) {
                    values.put(key, value.getValueFormatted());
                }
            }
        }

        Map<String, ActionInputParameter> pathVariableMap =
                getActionInputParameters(PathVariable.class, invokedMethod, arguments);
        for (Map.Entry<String, ActionInputParameter> entry : pathVariableMap.entrySet()) {
            ActionInputParameter actionInputParameter = entry.getValue();
            if (actionInputParameter != null) {
                final String key = entry.getKey();
                actionDescriptor.addPathVariable(key, actionInputParameter);
                if (!actionInputParameter.isRequestBody()) {
                    values.put(key, actionInputParameter.getValueFormatted());
                }
            }
        }

        Map<String, ActionInputParameter> requestHeadersMap =
                getActionInputParameters(RequestHeader.class, invokedMethod, arguments);

        for (Map.Entry<String, ActionInputParameter> entry : requestHeadersMap.entrySet()) {
            ActionInputParameter actionInputParameter = entry.getValue();
            if (actionInputParameter != null) {
                final String key = entry.getKey();
                actionDescriptor.addRequestHeader(key, actionInputParameter);
                if (!actionInputParameter.isRequestBody()) {
                    values.put(key, actionInputParameter.getValueFormatted());
                }
            }
        }

        return actionDescriptor;
    }

    private Cardinality getCardinality(Method invokedMethod, RequestMethod httpMethod, Type genericReturnType) {
        Cardinality cardinality;

        ResourceHandler resourceAnn = AnnotationUtils.findAnnotation(invokedMethod, ResourceHandler.class);
        if (resourceAnn != null) {
            cardinality = resourceAnn.value();
        } else {
            if (RequestMethod.POST == httpMethod || containsCollection(genericReturnType)) {
                cardinality = Cardinality.COLLECTION;
            } else {
                cardinality = Cardinality.SINGLE;
            }
        }
        return cardinality;
    }

    private boolean containsCollection(Type genericReturnType) {
        final boolean ret;
        if (genericReturnType instanceof ParameterizedType) {
            ParameterizedType t = (ParameterizedType) genericReturnType;
            Type rawType = t.getRawType();
            Assert.state(rawType instanceof Class<?>, "raw type is not a Class: " + rawType.toString());
            Class<?> cls = (Class<?>) rawType;
            if (HttpEntity.class.isAssignableFrom(cls)) {
                Type[] typeArguments = t.getActualTypeArguments();
                ret = containsCollection(typeArguments[0]);
            } else if (Resources.class.isAssignableFrom(cls) ||
                    Collection.class.isAssignableFrom(cls)) {
                ret = true;
            } else {
                ret = false;
            }
        } else if (genericReturnType instanceof GenericArrayType) {
            ret = true;
        } else if (genericReturnType instanceof WildcardType) {
            WildcardType t = (WildcardType) genericReturnType;
            ret = containsCollection(getBound(t.getLowerBounds())) || containsCollection(getBound(t.getUpperBounds()));
        } else if (genericReturnType instanceof TypeVariable) {
            ret = false;
        } else if (genericReturnType instanceof Class) {
            Class<?> cls = (Class<?>) genericReturnType;
            ret = Resources.class.isAssignableFrom(cls) ||
                    Collection.class.isAssignableFrom(cls);
        } else {
            ret = false;
        }
        return ret;
    }

    private Type getBound(Type[] lowerBounds) {
        Type ret;
        if (lowerBounds != null && lowerBounds.length > 0) {
            ret = lowerBounds[0];
        } else {
            ret = null;
        }
        return ret;
    }

    private static RequestMethod getHttpMethod(Method method) {
        RequestMapping methodRequestMapping = AnnotationUtils.findAnnotation(method, RequestMapping.class);
        RequestMethod requestMethod;
        if (methodRequestMapping != null) {
            RequestMethod[] methods = methodRequestMapping.method();
            if (methods.length == 0) {
                requestMethod = RequestMethod.GET;
            } else {
                requestMethod = methods[0];
            }
        } else {
            requestMethod = RequestMethod.GET; // default
        }
        return requestMethod;
    }

    /**
     * Returns {@link ActionInputParameter}s contained in the method link.
     *
     * @param annotation to inspect
     * @param method     must not be {@literal null}.
     * @param arguments  to the method link
     * @return maps parameter names to parameter info
     */
    private static Map<String, ActionInputParameter> getActionInputParameters(Class<? extends Annotation> annotation,
                                                                              Method method, Object... arguments
    ) {

        Assert.notNull(method, "MethodInvocation must not be null!");

        MethodParameters parameters = new MethodParameters(method);
        Map<String, ActionInputParameter> result = new HashMap<String, ActionInputParameter>();

        for (MethodParameter parameter : parameters.getParametersWith(annotation)) {
            final int parameterIndex = parameter.getParameterIndex();
            final Object argument;
            if (parameterIndex < arguments.length) {
                argument = arguments[parameterIndex];
            } else {
                argument = null;
            }
            ActionInputParameter inputParameter = new SpringActionInputParameter(parameter, argument);
            result.put(inputParameter.getParameterName(), inputParameter);
        }

        return result;
    }
}


package de.escalon.hypermedia;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Created by Dietrich on 11.03.2015.
 */
public class PropertyUtils {

    private PropertyUtils() {

    }

    public static Object getPropertyValue(Object currentCallValue, PropertyDescriptor propertyDescriptor) {
        Object propertyValue = null;
        if (currentCallValue != null && propertyDescriptor.getReadMethod() != null) {
            try {
                propertyValue = propertyDescriptor.getReadMethod()
                        .invoke(currentCallValue);
            } catch (Exception e) {
                throw new RuntimeException("failed to read property from call value", e);
            }
        }
        return propertyValue;
    }

    public static Map<String, PropertyDescriptor> getPropertyDescriptors(Object bean) {
        return PropertyUtils.getPropertyDescriptors(bean.getClass());
    }

    public static Map<String, PropertyDescriptor> getPropertyDescriptors(Class<?> clazz) {
        try {
            PropertyDescriptor[] propertyDescriptors = Introspector.getBeanInfo(clazz)
                    .getPropertyDescriptors();
            Map<String, PropertyDescriptor> ret = new LinkedHashMap<String, PropertyDescriptor>();
            for (PropertyDescriptor propertyDescriptor : propertyDescriptors) {
                ret.put(propertyDescriptor.getName(), propertyDescriptor);
            }
            return ret;
        } catch (IntrospectionException e) {
            throw new RuntimeException("failed to get property descriptors of class " + clazz.getSimpleName(), e);
        }
    }

    public static Constructor findDefaultCtor(Constructor[] constructors) {
        // TODO duplicate on HtmlResourceMessageConverter
        Constructor constructor = null;
        for (Constructor ctor : constructors) {
            if (ctor.getParameterTypes().length == 0) {
                constructor = ctor;
            }
        }
        return constructor;
    }

    public static Constructor findJsonCreator(Constructor[] constructors, Class creatorAnnotation) {
        // TODO duplicate on HtmlResourceMessageConverter
        Constructor constructor = null;
        for (Constructor ctor : constructors) {
            if (AnnotationUtils.findAnnotation(ctor, creatorAnnotation) != null) {
                constructor = ctor;
                break;
            }
        }
        return constructor;
    }

    // TODO move to PropertyUtil and remove current method for propertyDescriptors, cache search results
    public static Object getPropertyOrFieldValue(Object currentCallValue, String propertyOrFieldName) {
        if (currentCallValue == null) {
            return null;
        }
        Object propertyValue = getBeanPropertyValue(currentCallValue, propertyOrFieldName);
        if (propertyValue == null) {
            propertyValue = getFieldValue(currentCallValue, propertyOrFieldName);
        }
        return propertyValue;
    }

    public static Object getFieldValue(Object currentCallValue, String fieldName) {
        try {
            Class<?> beanType = currentCallValue.getClass();
            Object propertyValue = null;
            Field[] fields = beanType.getFields();
            for (Field field : fields) {
                if (fieldName.equals(field.getName())) {
                    propertyValue = field.get(currentCallValue);
                    break;
                }
            }
            return propertyValue;
        } catch (Exception e) {
            throw new RuntimeException("Failed to read field " + fieldName + " from " + currentCallValue.toString(), e);
        }
    }

    public static Object getBeanPropertyValue(Object currentCallValue, String paramName) {
        if (currentCallValue == null) {
            return null;
        }
        try {
            Object propertyValue = null;
            BeanInfo info = Introspector.getBeanInfo(currentCallValue.getClass());
            PropertyDescriptor[] pds = info.getPropertyDescriptors();
            for (PropertyDescriptor pd : pds) {
                if (paramName.equals(pd.getName())) {
                    Method readMethod = pd.getReadMethod();
                    if (readMethod != null) {
                        propertyValue = readMethod.invoke(currentCallValue);
                    }
                    break;
                }
            }
            return propertyValue;
        } catch (Exception e) {
            throw new RuntimeException("Failed to read property " + paramName + " from " + currentCallValue.toString
                    (), e);
        }
    }

}