package com.thinkbiganalytics.metadata.modeshape.support;

/*-
 * #%L
 * thinkbig-metadata-modeshape
 * %%
 * Copyright (C) 2017 ThinkBig Analytics
 * %%
 * 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.
 * #L%
 */

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.datatype.joda.JodaModule;
import com.thinkbiganalytics.classnameregistry.ClassNameChangeRegistry;
import com.thinkbiganalytics.metadata.api.MissingUserPropertyException;
import com.thinkbiganalytics.metadata.api.extension.ExtensibleType;
import com.thinkbiganalytics.metadata.api.extension.ExtensibleTypeBuilder;
import com.thinkbiganalytics.metadata.api.extension.ExtensibleTypeProvider;
import com.thinkbiganalytics.metadata.api.extension.FieldDescriptor;
import com.thinkbiganalytics.metadata.api.extension.FieldDescriptorBuilder;
import com.thinkbiganalytics.metadata.api.extension.UserFieldDescriptor;
import com.thinkbiganalytics.metadata.modeshape.JcrMetadataAccess;
import com.thinkbiganalytics.metadata.modeshape.MetadataRepositoryException;
import com.thinkbiganalytics.metadata.modeshape.UnknownPropertyException;
import com.thinkbiganalytics.metadata.modeshape.common.JcrObject;
import com.thinkbiganalytics.metadata.modeshape.extension.JcrExtensiblePropertyCollection;
import com.thinkbiganalytics.metadata.modeshape.extension.JcrUserFieldDescriptor;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.joda.time.DateTime;

import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import javax.annotation.Nonnull;
import javax.jcr.Binary;
import javax.jcr.ItemNotFoundException;
import javax.jcr.Node;
import javax.jcr.PathNotFoundException;
import javax.jcr.Property;
import javax.jcr.PropertyIterator;
import javax.jcr.PropertyType;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.Value;
import javax.jcr.ValueFactory;
import javax.jcr.nodetype.NodeType;
import javax.jcr.nodetype.PropertyDefinition;

/**
 * Utility functions for JCR properties.
 */
public class JcrPropertyUtil {

    /**
     * Encoding for user-defined property names
     */
    public static final String USER_PROPERTY_ENCODING = "UTF-8";

    protected static final ObjectWriter writer;
    protected static final ObjectReader reader;

    static {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JodaModule());
        mapper.setSerializationInclusion(Include.NON_NULL);

        mapper.setVisibility(mapper.getSerializationConfig().getDefaultVisibilityChecker()
                                 .withFieldVisibility(JsonAutoDetect.Visibility.ANY)
                                 .withGetterVisibility(JsonAutoDetect.Visibility.NONE)
                                 .withIsGetterVisibility(JsonAutoDetect.Visibility.NONE));

        reader = mapper.reader();
        writer = mapper.writer();
    }


    /**
     * Instances of {@code JcrPropertyUtil} may not be constructed.
     *
     * @throws UnsupportedOperationException always
     */
    private JcrPropertyUtil() {
        throw new UnsupportedOperationException();
    }

    public static String getName(Node node) {
        try {
            return node.getName();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access name property of node: " + node, e);
        }
    }

    public static String getName(Property prop) {
        try {
            return prop.getName();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get the name of property: " + prop, e);
        }
    }

    public static String getString(Node node, String name) {
        try {
            Property prop = node.getProperty(name);
            return prop.getString();
        } catch (PathNotFoundException e) {
            return null;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access property: " + name, e);
        }
    }

    /**
     * Returns the boolean value of the property. if the property is a String type it will try to determine the boolean value. If it cannot get a boolean value, it will return false.
     */
    public static boolean getBoolean(Node node, String name) {
        return getBoolean(node, name, false);
    }

    private static boolean getBoolean(Node node, String name, boolean notFoundValue) {
        try {
            Property prop = node.getProperty(name);
            if (PropertyType.STRING == prop.getType()) {
                return BooleanUtils.toBoolean(prop.getString());
            } else if (PropertyType.BOOLEAN == prop.getType()) {
                return prop.getBoolean();
            }
            return notFoundValue;
        } catch (PathNotFoundException e) {
            return notFoundValue;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access property: " + name, e);
        }
    }

    public static boolean getBooleanOrDefault(Node node, String name, boolean notFoundValue) {
        boolean val = notFoundValue;
        try {
            val = getBoolean(node, name);
        } catch (Exception e) {
            //swallow exception and return the default value
        }
        return val;
    }

    public static Long getLong(Node node, String name) {
        try {
            Property prop = node.getProperty(name);
            return prop.getLong();
        } catch (PathNotFoundException e) {
            return null;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access property: " + name, e);
        }
    }

    public static <E extends Enum<E>> E getEnum(Node node, String name, Class<E> enumType, E defaultValue) {
        try {
            Property prop = node.getProperty(name);
            return Enum.valueOf(enumType, prop.getString());
        } catch (PathNotFoundException e) {
            return defaultValue;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access property: " + name, e);
        }
    }

    private static <T> T readJsonValue(String name, Class<T> type, String json) {
        try {
            return reader.forType(type).readValue(json);
        } catch (IOException e) {
            if (ExceptionUtils.getRootCause(e) instanceof ClassNotFoundException) {
                //attempt to find the old class name and replace it with the new one
                ClassNotFoundException classNotFoundException = (ClassNotFoundException) ExceptionUtils.getRootCause(e);
                String msg = classNotFoundException.getMessage();
                msg = StringUtils.remove(msg, "java.lang.ClassNotFound:");
                String oldName = StringUtils.trim(msg);
                try {
                    Class newName = ClassNameChangeRegistry.findClass(oldName);
                    String newNameString = newName.getName();
                    if (StringUtils.contains(json, oldName)) {
                        //replace and try again
                        json = StringUtils.replace(json, oldName, newNameString);
                        return readJsonValue(name, type, json);
                    }
                } catch (ClassNotFoundException c) {

                }


            }
            throw new MetadataRepositoryException("Failed to deserialize JSON property: " + name, e);
        }
    }

    public static <T> T getJsonObject(Node node, String name, Class<T> type) {
        String json = getString(node, name);
        return readJsonValue(name, type, json);
    }

    public static <T> void setJsonObject(Node node, String name, Object value) {
        try {
            String json = writer.forType(value.getClass()).writeValueAsString(value);

            setProperty(node, name, json);
        } catch (IOException e) {
            throw new MetadataRepositoryException("Failed to serialize JSON property: " + value, e);
        }
    }

    public static Optional<Property> findProperty(Node node, String name) {
        try {
            if (node.hasProperty(name)) {
                return Optional.of(node.getProperty(name));
            } else {
                return Optional.empty();
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed attempting to locate a property: " + name, e);
        }
    }

    public static <T> T getProperty(Node node, String name) {
        return getProperty(node, name, false);
    }

    public static <T> T getProperty(Node node, String name, boolean allowNotFound) {
        try {
            Property prop = node.getProperty(name);
            return asValue(prop);
        } catch (PathNotFoundException e) {
            if (allowNotFound) {
                return null;
            } else {
                throw new UnknownPropertyException(name, e);
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access property: " + name, e);
        }
    }

    public static Map<String, Object> getProperties(Node node) {
        try {
            Map<String, Object> propMap = new HashMap<>();
            PropertyIterator itr = node.getProperties();

            while (itr.hasNext()) {
                Property prop = (Property) itr.next();
                propMap.put(prop.getName(), asValue(prop));
            }

            return propMap;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access properties", e);
        }
    }

    public static Node setProperties(Session session, Node entNode, Map<String, Object> props) {
        ValueFactory factory;
        try {
            factory = session.getValueFactory();

            if (props != null) {
                JcrMetadataAccess.ensureCheckoutNode(entNode);
                for (Map.Entry<String, Object> entry : props.entrySet()) {
                    if (entry.getValue() instanceof JcrExtensiblePropertyCollection) {
                        JcrExtensiblePropertyCollection propertyCollection = ((JcrExtensiblePropertyCollection) entry.getValue());
                        propertyCollection.getCollectionType();
                        Value[] values = new Value[propertyCollection.getCollection().size()];
                        int i = 0;
                        for (Object o : propertyCollection.getCollection()) {
                            boolean weak = false;
                            if (propertyCollection.getCollectionType() == PropertyType.WEAKREFERENCE) {
                                weak = true;
                            }
                            Value value = createValue(session, o, weak);
                            values[i] = value;
                            i++;
                        }
                        entNode.setProperty(entry.getKey(), values);
                    } else {
                        Value value = asValue(factory, entry.getValue());
                        entNode.setProperty(entry.getKey(), value);
                    }
                }
            }

            return entNode;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to set properties", e);
        }
    }

    public static boolean hasProperty(NodeType type, String propName) {
        for (PropertyDefinition propDef : type.getPropertyDefinitions()) {
            if (propDef.getName().equals(propName)) {
                return true;
            }
        }

        return false;
    }

//
//    public static <T> T asValue(Property prop) {
//        return asValue(prop, null);
//    }

    @SuppressWarnings("unchecked")
    public static <T> T asValue(Value value, Session session) {
        try {
            switch (value.getType()) {
                case PropertyType.DECIMAL:
                    return (T) value.getDecimal();
                case PropertyType.STRING:
                    return (T) value.getString();
                case PropertyType.DOUBLE:
                    return (T) Double.valueOf(value.getDouble());
                case PropertyType.LONG:
                    return (T) Long.valueOf(value.getLong());
                case PropertyType.BOOLEAN:
                    return (T) Boolean.valueOf(value.getBoolean());
                case PropertyType.DATE:
                    return (T) new DateTime(value.getDate().getTime());
                case PropertyType.BINARY:
                    return (T) IOUtils.toByteArray(value.getBinary().getStream());
                case PropertyType.REFERENCE:
                    String ref = value.getString();
                    return (T) session.getNodeByIdentifier(ref);
                case PropertyType.WEAKREFERENCE:
                    String wref = value.getString();
                    try {
                        return (T) session.getNodeByIdentifier(wref);
                    } catch (ItemNotFoundException e) {
                        return null;
                    }
                default:
                    return (T) value.getString();
            }
        } catch (RepositoryException | IOException e) {
            throw new MetadataRepositoryException("Failed to access property type", e);
        }
    }

    public static String toString(Property prop) {
        try {
            return prop.getString();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get string value of property: " + prop, e);
        }
    }

    @SuppressWarnings("unchecked")
    public static <T> T asValue(Property prop) {
        // STRING, BOOLEAN, LONG, DOUBLE, PATH, ENTITY
        try {
            int code = prop.getType();
            if (prop.isMultiple()) {
                List<T> list = new ArrayList<>();
                Value[] values = prop.getValues();
                if (values != null) {
                    for (Value value : values) {
                        T o = asValue(value, prop.getSession());
                        list.add(o);
                    }
                }
                if (list.size() > 0) {
                    return (T) list;
                } else {
                    return (T) Collections.emptyList();
                }
            } else {
                if (code == PropertyType.BOOLEAN) {
                    return (T) Boolean.valueOf(prop.getBoolean());
                } else if (code == PropertyType.STRING) {
                    return (T) prop.getString();
                } else if (code == PropertyType.LONG) {
                    return (T) Long.valueOf(prop.getLong());
                } else if (code == PropertyType.DOUBLE) {
                    return (T) Double.valueOf(prop.getDouble());
                } else if (code == PropertyType.PATH) {
                    return (T) prop.getPath();
                } else if (code == PropertyType.REFERENCE || code == PropertyType.WEAKREFERENCE) {
                    return (T) prop.getNode();
                } else {
                    return (T) asValue(prop.getValue(), prop.getSession());
                }
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to access property type", e);
        }
    }

    public static Node lookupNodeReference(String nodeIdentifier, Session session) {
        Node n = null;
        if (session != null) {
            try {
                n = session.getNodeByIdentifier(nodeIdentifier);
            } catch (RepositoryException e) {

            }
        }
        return n;
    }

    public static void setWeakReferenceProperty(Node node, String name, Node ref) {
        try {
            //ensure checked out
            JcrMetadataAccess.ensureCheckoutNode(node);

            if (node == null) {
                throw new IllegalArgumentException("Cannot set a property on a null-node!");
            }
            if (name == null) {
                throw new IllegalArgumentException("Cannot set a property without a provided name");
            }

            Value weakRef = node.getSession().getValueFactory().createValue(ref, true);
            node.setProperty(name, weakRef);

        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to set weak ref property value: " + name + "=" + ref, e);
        }

    }

    public static void setProperty(Node node, String name, Object value) {
        try {
            //ensure checked out
            JcrMetadataAccess.ensureCheckoutNode(node);

            if (node == null) {
                throw new IllegalArgumentException("Cannot set a property on a null-node!");
            }
            if (name == null) {
                throw new IllegalArgumentException("Cannot set a property without a provided name");
            }

            if (value == null) {
                node.setProperty(name, (Value) null);
            } else if (value instanceof Enum) {
                node.setProperty(name, ((Enum) value).name());
            } else if (value instanceof JcrObject) {
                node.setProperty(name, ((JcrObject) value).getNode());
            } else if (value instanceof Value) {
                node.setProperty(name, (Value) value);
            } else if (value instanceof Node) {
                node.setProperty(name, (Node) value);
            } else if (value instanceof Binary) {
                node.setProperty(name, (Binary) value);
            } else if (value instanceof Calendar) {
                node.setProperty(name, (Calendar) value);
            } else if (value instanceof DateTime) {
                Calendar cal = Calendar.getInstance();
                cal.setTime(((DateTime) value).toDate());
                node.setProperty(name, cal);
            } else if (value instanceof Date) {
                Calendar cal = Calendar.getInstance();
                cal.setTime((Date) value);
                node.setProperty(name, cal);
            } else if (value instanceof BigDecimal) {
                node.setProperty(name, (BigDecimal) value);
            } else if (value instanceof Long) {
                node.setProperty(name, ((Long) value).longValue());
            } else if (value instanceof Double) {
                node.setProperty(name, (Double) value);
            } else if (value instanceof Boolean) {
                node.setProperty(name, (Boolean) value);
            } else if (value instanceof InputStream) {
                node.setProperty(name, (InputStream) value);
            } else if (value instanceof Collection) {
                String[] list = new String[((Collection<Object>) value).size()];
                int pos = 0;
                for (Object cal : (Collection<Object>) value) {
                    list[pos] = cal.toString();
                    pos += 1;
                }
                node.setProperty(name, list);
            } else {
                node.setProperty(name, value.toString());
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to set property value: " + name + "=" + value, e);
        }
    }

    /**
     * Used to retrieve the referenced nodes from a multi-valued property of type (WEAK)REFERENCE
     */
    public static Set<Node> getReferencedNodeSet(Node node, String propName) {
        try {
            if (node == null) {
                throw new IllegalArgumentException("Cannot set a property on a null-node!");
            }
            if (propName == null) {
                throw new IllegalArgumentException("Cannot set a property without a provided name");
            }

            final Session session = node.getSession();
            JcrMetadataAccess.ensureCheckoutNode(node);

            if (node.hasProperty(propName)) {
                return Arrays.stream(node.getProperty(propName).getValues())
                    .map(v -> (Node) JcrPropertyUtil.asValue(v, session))
                    .filter(n -> n != null)  // weak refs can produce null nodes
                    .collect(Collectors.toSet());
            } else {
                return new HashSet<>();
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get the node property set: " + propName, e);
        }
    }

    public static boolean addToSetProperty(Node node, String name, Object value) {
        return addToSetProperty(node, name, value, false);
    }

    public static boolean addToSetProperty(Node node, String name, Object value, boolean weakReference) {
        try {
            JcrMetadataAccess.ensureCheckoutNode(node);

            if (node == null) {
                throw new IllegalArgumentException("Cannot set a property on a null-node!");
            }
            if (name == null) {
                throw new IllegalArgumentException("Cannot set a property without a provided name");
            }

            Set<Value> values = null;

            if (node.hasProperty(name)) {
                values = Arrays.stream(node.getProperty(name).getValues()).map(v -> {
                    if (PropertyType.REFERENCE == v.getType() && weakReference) {
                        try {
                            Node n = JcrPropertyUtil.asValue(v, node.getSession());
                            return n.getSession().getValueFactory().createValue(n, true);
                        } catch (RepositoryException e) {
                            throw new MetadataRepositoryException("Failed to add to set property: " + name + "->" + value, e);
                        }
                    } else {
                        return v;
                    }
                }).collect(Collectors.toSet());
            } else {
                values = new HashSet<>();
            }

            Value newVal = createValue(node.getSession(), value, weakReference);

            boolean result = values.add(newVal);
            if (weakReference) {
                Property property = node.setProperty(name, (Value[]) values.stream().toArray(size -> new Value[size]), PropertyType.WEAKREFERENCE);
            } else {
                Property property = node.setProperty(name, (Value[]) values.stream().toArray(size -> new Value[size]));
            }

            return result;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to add to set property: " + name + "->" + value, e);
        }
    }

    public static boolean removeAllFromSetProperty(Node node, String name) {
        try {
            JcrMetadataAccess.ensureCheckoutNode(node);
            if (node == null) {
                throw new IllegalArgumentException("Cannot remove a property from a null-node!");
            }
            if (name == null) {
                throw new IllegalArgumentException("Cannot remove a property without a provided name");
            }

            Set<Value> values = new HashSet<>();
            node.setProperty(name, (Value[]) values.stream().toArray(size -> new Value[size]));
            return true;

        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove set property: " + name, e);
        }
    }

    public static boolean removeFromSetProperty(Node node, String name, Object value) {
        try {
            JcrMetadataAccess.ensureCheckoutNode(node);

            if (node == null) {
                throw new IllegalArgumentException("Cannot remove a property from a null-node!");
            }
            if (name == null) {
                throw new IllegalArgumentException("Cannot remove a property without a provided name");
            }

            Set<Value> values = new HashSet<>();

            if (node.hasProperty(name)) {
                values = Arrays.stream(node.getProperty(name).getValues()).collect(Collectors.toSet());
            } else {
                values = new HashSet<>();
            }

            Value existingVal = createValue(node.getSession(), value);
            boolean result = values.remove(existingVal);
            node.setProperty(name, (Value[]) values.stream().toArray(size -> new Value[size]));
            return result;
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to remove from set property: " + name + "->" + value, e);
        }
    }

    public static <T> Set<T> getSetProperty(Node node, String name) {
        try {
            if (node == null) {
                throw new IllegalArgumentException("Cannot set a property on a null-node!");
            }
            if (name == null) {
                throw new IllegalArgumentException("Cannot set a property without a provided name");
            }

            if (node.hasProperty(name)) {
                Set<T> result = new HashSet<T>((Collection<T>) getProperty(node, name));
                return result;
            } else {
                return Collections.emptySet();
            }
        } catch (ClassCastException e) {
            throw new MetadataRepositoryException("Wrong property data type for set", e);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get set property: " + name, e);
        }
    }

    public static Value createValue(Session session, Object value) {
        return createValue(session, value, false);
    }

    public static Value createValue(Session session, Object value, boolean weakRef) {
        try {
            ValueFactory factory = session.getValueFactory();
            if (value == null) {
                throw new IllegalArgumentException("Cannot create a value from null");
            } else if (value instanceof Enum) {
                return factory.createValue(((Enum) value).name());
            } else if (value instanceof JcrObject) {
                return factory.createValue(((JcrObject) value).getNode(), weakRef);
//                return factory.createValue(((JcrObject) value).getNode().getIdentifier(), weakRef ? PropertyType.WEAKREFERENCE : PropertyType.REFERENCE);
            } else if (value instanceof Value) {
                return (Value) value;
            } else if (value instanceof Node) {
                return factory.createValue((Node) value, weakRef);
//                return factory.createValue(((Node) value).getIdentifier(), weakRef ? PropertyType.WEAKREFERENCE : PropertyType.REFERENCE);
            } else if (value instanceof Binary) {
                return factory.createValue((Binary) value);
            } else if (value instanceof Calendar) {
                return factory.createValue((Calendar) value);
            } else if (value instanceof DateTime) {
                Calendar cal = Calendar.getInstance();
                cal.setTime(((DateTime) value).toDate());
                return factory.createValue(cal);
            } else if (value instanceof Date) {
                Calendar cal = Calendar.getInstance();
                cal.setTime((Date) value);
                return factory.createValue(cal);
            } else if (value instanceof BigDecimal) {
                return factory.createValue((BigDecimal) value);
            } else if (value instanceof Long) {
                return factory.createValue(((Long) value).longValue());
            } else if (value instanceof Double) {
                return factory.createValue((Double) value);
            } else if (value instanceof Boolean) {
                return factory.createValue((Boolean) value);
            } else if (value instanceof InputStream) {
                return factory.createValue((InputStream) value);
//        } else if (value instanceof Collection) {
//            String[] list = new String[((Collection<Object>) value).size()];
//            int pos = 0;
//            for (Object cal : (Collection<Object>) value) {
//                list[pos] = cal.toString();
//                pos += 1;
//            }
//            return factory.createValue(list);
            } else {
                return factory.createValue(value.toString());
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to create value frpm: " + value, e);
        }
    }

    public static int getJCRPropertyType(Object obj) {
        if (obj instanceof String) {
            return PropertyType.STRING;
        }
        if (obj instanceof Double) {
            return PropertyType.DOUBLE;
        }
        if (obj instanceof Float) {
            return PropertyType.DOUBLE;
        }
        if (obj instanceof Long) {
            return PropertyType.LONG;
        }
        if (obj instanceof Integer) {
            return PropertyType.LONG;
        }
        if (obj instanceof Boolean) {
            return PropertyType.BOOLEAN;
        }
        if (obj instanceof Calendar) {
            return PropertyType.DATE;
        }
        if (obj instanceof Binary) {
            return PropertyType.BINARY;
        }
        if (obj instanceof InputStream) {
            return PropertyType.BINARY;
        }
        if (obj instanceof JcrExtensiblePropertyCollection) {
            return JcrExtensiblePropertyCollection.COLLECTION_TYPE;
        }
        if (obj instanceof Node) {
            return PropertyType.REFERENCE;
        }
        return PropertyType.UNDEFINED;
    }

    public static Value asValue(ValueFactory factory, Object obj) {
        // STRING, BOOLEAN, LONG, DOUBLE, PATH, ENTITY
        try {
            switch (getJCRPropertyType(obj)) {
                case PropertyType.STRING:
                    return factory.createValue((String) obj);
                case PropertyType.BOOLEAN:
                    return factory.createValue((Boolean) obj);
                case PropertyType.DATE:
                    return factory.createValue((Calendar) obj);
                case PropertyType.LONG:
                    return obj instanceof Long ? factory.createValue(((Long) obj).longValue()) : factory.createValue(((Integer) obj).longValue());
                case PropertyType.DOUBLE:
                    return obj instanceof Double ? factory.createValue((Double) obj) : factory.createValue(((Float) obj).doubleValue());
                case PropertyType.BINARY:
                    return factory.createValue((InputStream) obj);
                case PropertyType.REFERENCE:
                    return factory.createValue((Node) obj);
                default:
                    return (obj != null ? factory.createValue(obj.toString()) : factory.createValue(StringUtils.EMPTY));
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Invalid value format", e);
        }
    }

    /**
     * Assuming the specified property is a (WEAK)REFERENCE type, returns whether it is pointing at the specified node.
     */
    public static boolean isReferencing(Node node, String refProp, Node targetNode) {
        try {
            return isReferencing(node, refProp, targetNode.getIdentifier());
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to check reference property against node: " + node, e);
        }
    }

    /**
     * Assuming the specified property is a (WEAK)REFERENCE type, returns whether it is pointing at the specified node ID.
     */
    public static boolean isReferencing(Node node, String refProp, String nodeId) {
        try {
            return node.getProperty(refProp).getNode().getIdentifier().equals(nodeId);
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to check reference property against node ID: " + nodeId, e);
        }
    }

    /**
     * Gets the user-defined fields for the specified type.
     *
     * @param name                   the type name
     * @param extensibleTypeProvider the type provider
     * @return the user-defined fields
     */
    @Nonnull
    public static Set<UserFieldDescriptor> getUserFields(@Nonnull final String name, @Nonnull final ExtensibleTypeProvider extensibleTypeProvider) {
        final ExtensibleType type = extensibleTypeProvider.getType(name);
        return (type != null) ? type.getUserFieldDescriptors() : Collections.emptySet();
    }

    /**
     * Sets the user-defined fields for the specified type.
     *
     * @param name                   the type name
     * @param fields                 the user-defined fields
     * @param extensibleTypeProvider the type provider
     */
    public static void setUserFields(@Nonnull final String name, @Nonnull final Set<UserFieldDescriptor> fields, @Nonnull final ExtensibleTypeProvider extensibleTypeProvider) {
        // Get type builder
        final ExtensibleTypeBuilder builder;
        final ExtensibleType type = extensibleTypeProvider.getType(name);

        if (type == null) {
            builder = extensibleTypeProvider.buildType(name);
        } else {
            builder = extensibleTypeProvider.updateType(type.getId());
        }

        // Add fields to type
        final String prefix = JcrMetadataAccess.USR_PREFIX + ":";

        fields.forEach(field -> {
            // Encode field name
            final String systemName;
            try {
                systemName = prefix + URLEncoder.encode(field.getSystemName(), USER_PROPERTY_ENCODING);
            } catch (UnsupportedEncodingException e) {
                throw new IllegalStateException(e.toString(), e);
            }

            // Create field descriptor
            final FieldDescriptorBuilder fieldBuilder = builder.field(systemName);
            fieldBuilder.description(field.getDescription());
            fieldBuilder.displayName(field.getDisplayName());
            fieldBuilder.type(FieldDescriptor.Type.STRING);
            fieldBuilder.property(JcrUserFieldDescriptor.ORDER, Integer.toString(field.getOrder()));
            fieldBuilder.property(JcrUserFieldDescriptor.REQUIRED, Boolean.toString(field.isRequired()));
            fieldBuilder.add();
        });
        builder.build();
    }

    /**
     * Gets the user-defined property names and values for the specified node.
     *
     * @param node the node to be searched
     * @return a map of property names to values
     * @throws IllegalStateException       if a property name is encoded incorrectly
     * @throws MetadataRepositoryException if the metadata repository is unavailable
     */
    @Nonnull
    public static Map<String, String> getUserProperties(@Nonnull final Node node) {
        // Get node properties
        final PropertyIterator iterator;
        try {
            iterator = node.getProperties();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get properties for node: " + node, e);
        }

        // Convert iterator to map
        final String prefix = JcrMetadataAccess.USR_PREFIX + ":";
        final int prefixLength = prefix.length();
        final Map<String, String> properties = new HashMap<>((int) Math.min(iterator.getSize(), Integer.MAX_VALUE));

        while (iterator.hasNext()) {
            final Property property = iterator.nextProperty();
            try {
                if (property.getName().startsWith(prefix)) {
                    properties.put(URLDecoder.decode(property.getName().substring(prefixLength), USER_PROPERTY_ENCODING), property.getString());
                }
            } catch (RepositoryException e) {
                throw new MetadataRepositoryException("Failed to access property \"" + property + "\" on node: " + node, e);
            } catch (UnsupportedEncodingException e) {
                throw new IllegalStateException("Unsupported encoding for property \"" + property + "\" on node: " + node, e);
            }
        }

        return properties;
    }

    /**
     * Sets the specified user-defined properties on the specified node.
     *
     * @param node       the target node
     * @param fields     the predefined user fields
     * @param properties the map of user-defined property names to values
     * @throws IllegalStateException       if a property name is encoded incorrectly
     * @throws MetadataRepositoryException if the metadata repository is unavailable
     */
    public static void setUserProperties(@Nonnull final Node node, @Nonnull final Set<UserFieldDescriptor> fields, @Nonnull final Map<String, String> properties) {
        // Verify required properties are not empty
        for (final UserFieldDescriptor field : fields) {
            if (field.isRequired() && StringUtils.isEmpty(properties.get(field.getSystemName()))) {
                throw new MissingUserPropertyException("Missing required property: " + field.getSystemName());
            }
        }

        // Set properties on node
        final Set<String> newProperties = new HashSet<>(properties.size());
        final String prefix = JcrMetadataAccess.USR_PREFIX + ":";

        properties.forEach((key, value) -> {
            try {
                final String name = prefix + URLEncoder.encode(key, USER_PROPERTY_ENCODING);
                newProperties.add(name);
                node.setProperty(name, value);
            } catch (RepositoryException e) {
                throw new MetadataRepositoryException("Failed to set user property \"" + key + "\" on node: " + node, e);
            } catch (UnsupportedEncodingException e) {
                throw new IllegalStateException(e.toString(), e);
            }
        });

        // Get node properties
        final PropertyIterator iterator;
        try {
            iterator = node.getProperties();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get properties for node: " + node, e);
        }

        // Remove properties from node
        while (iterator.hasNext()) {
            final Property property = iterator.nextProperty();
            try {
                final String name = property.getName();
                if (name.startsWith(prefix) && !newProperties.contains(name)) {
                    property.remove();
                }
            } catch (RepositoryException e) {
                throw new MetadataRepositoryException("Failed to remove property \"" + property + "\" on node: " + node, e);
            }
        }
    }

    /**
     * Copies a property, if present, from the source node to the destination node.
     *
     * @param src  source node
     * @param dest destination node
     * @param name the name of the property
     * @return whether the source had a value to copy
     */
    public static boolean copyProperty(Node src, Node dest, String name) {
        try {
            if (src.hasProperty(name)) {
                Property prop = src.getProperty(name);

                if (prop.isMultiple()) {
                    Value[] values = prop.getValues();
                    dest.setProperty(name, values, prop.getType());
                } else {
                    Value value = prop.getValue();
                    dest.setProperty(name, value, prop.getType());
                }

                return true;
            } else {
                return false;
            }
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to copy property \"" + name + "\" from node " + src + " to " + dest, e);
        }
    }

    public static Node getParent(Property prop) {
        try {
            return prop.getParent();
        } catch (RepositoryException e) {
            throw new MetadataRepositoryException("Failed to get the parent node of the property: " + prop, e);
        }
    }

    @SuppressWarnings("unchecked")
    public static Iterable<Property> propertiesIterable(Node node) {
        return () -> {
            try {
                return node.getProperties();
            } catch (RepositoryException e) {
                throw new MetadataRepositoryException("Failed to get the properties of node: " + node, e);
            }
        };
    }

    /**
     * @param node
     */
    public static Stream<Property> streamProperties(Node node) {
        return StreamSupport.stream(propertiesIterable(node).spliterator(), false);
    }
}
