/*
 * RHQ Management Platform
 * Copyright (C) 2005-2008 Red Hat, Inc.
 * All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */
package org.rhq.enterprise.server.safeinvoker;

import java.beans.BeanInfo;
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.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;

import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlTransient;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.hibernate.Hibernate;
import org.hibernate.proxy.HibernateProxy;

/**
 * This is a single static utility that is used to process any object just prior to sending it over the wire
 * to remote clients (like GWT clients or remote web service clients).
 * 
 * Essentially this utility scrubs the object of all Hibernate proxies, cleaning it such that it
 * can be serialized over the wire successfully.
 *  
 * @author Greg Hinkle
 * @author Jay Shaughnessy
 * @author John Mazzitelli
 */
@SuppressWarnings("unchecked")
public class HibernateDetachUtility {

    private static final Log LOG = LogFactory.getLog(HibernateDetachUtility.class);

    public static enum SerializationType {
        SERIALIZATION, JAXB
    }

    /*
     * This is the type of object that will be used to generate identity
     * hashcodes for objects that are being scanned during the detach.
     * During production runtime, the hashCodeGenerator will use the
     * java.lang.System mechanism, but since this is package scoped,
     * tests can override this since. (See BZ 732089).
     */
    static interface HashCodeGenerator {
        Integer getHashCode(Object value);
    }

    static class SystemHashCodeGenerator implements HashCodeGenerator {
        @Override
        public Integer getHashCode(Object value) {
            return System.identityHashCode(value);
        }
    }

    static HashCodeGenerator hashCodeGenerator = new SystemHashCodeGenerator();

    // be able to configure the deepest recursion this utility will be allowed to go (see BZ 702109 that precipitated this need)
    private static final String DEPTH_ALLOWED_SYSPROP = "rhq.server.hibernate-detach-utility.depth-allowed";
    private static final String THROW_EXCEPTION_ON_DEPTH_LIMIT_SYSPROP = "rhq.server.hibernate-detach-utility.throw-exception-on-depth-limit";
    private static final int depthAllowed;
    private static final boolean throwExceptionOnDepthLimit;
    private static final String DUMP_STACK_THRESHOLDS_SYSPROP = "rhq.server.hibernate-detach-utility.dump-stack-thresholds"; // e.g. "5000:10000" (millis:num-objects)
    private static final boolean dumpStackOnThresholdLimit;
    private static final long millisThresholdLimit;
    private static final int sizeThresholdLimit;

    static {
        int value;
        try {
            String str = System.getProperty(DEPTH_ALLOWED_SYSPROP, "100");
            value = Integer.parseInt(str);
        } catch (Throwable t) {
            value = 100;
        }
        depthAllowed = value;

        boolean booleanValue;
        try {
            String str = System.getProperty(THROW_EXCEPTION_ON_DEPTH_LIMIT_SYSPROP, "true");
            booleanValue = Boolean.parseBoolean(str);
        } catch (Throwable t) {
            booleanValue = true;
        }
        throwExceptionOnDepthLimit = booleanValue;

        boolean tmp_dumpStackOnThresholdLimit;
        long tmp_millisThresholdLimit;
        int tmp_sizeThresholdLimit;
        String prop = null;
        try {
            prop = System.getProperty(DUMP_STACK_THRESHOLDS_SYSPROP);
            if (prop != null) {
                String[] nums = prop.split(":");
                tmp_dumpStackOnThresholdLimit = true;
                tmp_millisThresholdLimit = Long.parseLong(nums[0]);
                tmp_sizeThresholdLimit = Integer.parseInt(nums[1]);
            } else {
                tmp_dumpStackOnThresholdLimit = false;
                tmp_millisThresholdLimit = Long.MAX_VALUE;
                tmp_sizeThresholdLimit = Integer.MAX_VALUE;
            }
        } catch (Throwable t) {
            LOG.warn("Bad value for [" + DUMP_STACK_THRESHOLDS_SYSPROP + "]=[" + prop + "]: " + t.toString());
            tmp_dumpStackOnThresholdLimit = true; // they wanted to set it to something, so give them some defaults
            tmp_millisThresholdLimit = 5000L; // 5 seconds
            tmp_sizeThresholdLimit = 10000; // 10K objects
        }
        dumpStackOnThresholdLimit = tmp_dumpStackOnThresholdLimit;
        millisThresholdLimit = tmp_millisThresholdLimit;
        sizeThresholdLimit = tmp_sizeThresholdLimit;
    }

    public static void nullOutUninitializedFields(Object value, SerializationType serializationType) throws Exception {
        long start = System.currentTimeMillis();
        Map<Integer, Object> checkedObjectMap = new HashMap<Integer, Object>();
        Map<Integer, List<Object>> checkedObjectCollisionMap = new HashMap<Integer, List<Object>>();
        nullOutUninitializedFields(value, checkedObjectMap, checkedObjectCollisionMap, 0, serializationType);
        long duration = System.currentTimeMillis() - start;

        if (dumpStackOnThresholdLimit) {
            int numObjectsProcessed = checkedObjectMap.size();
            if (duration > millisThresholdLimit || numObjectsProcessed > sizeThresholdLimit) {
                String rootObjectString = (value != null) ? value.getClass().toString() : "null";
                LOG.warn("Detached [" + numObjectsProcessed + "] objects in [" + duration + "]ms from root object ["
                    + rootObjectString + "]", new Throwable("HIBERNATE DETACH UTILITY STACK TRACE"));
            }
        } else {
            // 10s is really long, log SOMETHING
            if (duration > 10000L && LOG.isDebugEnabled()) {
                LOG.debug("Detached [" + checkedObjectMap.size() + "] objects in [" + duration + "]ms");
            }
        }

        // help the garbage collector be clearing these before we leave
        checkedObjectMap.clear();
        checkedObjectCollisionMap.clear();
    }

    /**
     * @param value the object needing to be detached/scrubbed.
     * @param checkedObjectMap This maps identityHashCodes to Objects we've already detached. In that way we can
     * quickly determine if we've already done the work for the incoming value and avoid taversing it again. This
     * works well almost all of the time, but it is possible that two different objects can have the same identity hash
     * (conflicts are always possible with a hash). In that case we utilize the checkedObjectCollisionMap (see below).
     * @param checkedObjectCollisionMap checkedObjectMap maps the identityhash to the *first* object with that hash. In
     * most cases there will only be mapping for one hash, but it is possible to encounter the same hash for multiple
     * objects, especially on 32bit or IBM JVMs. It is important to know if an object has already been detached
     * because if it is somehow self-referencing, we have to stop the recursion. This map holds the 2nd..Nth mapping
     * for a single hash and is used to ensure we never try to detach an object already processed.
     * @param depth used to stop infinite recursion, defaults to a depth we don't expectto see, but it is configurable.
     * @param serializationType
     * @throws Exception if a problem occurs
     * @throws IllegalStateException if the recursion depth limit is reached
     */
    private static void nullOutUninitializedFields(Object value, Map<Integer, Object> checkedObjectMap,
        Map<Integer, List<Object>> checkedObjectCollisionMap, int depth, SerializationType serializationType)
        throws Exception {
        if (depth > depthAllowed) {
            String warningMessage = "Recursed too deep [" + depth + " > " + depthAllowed
                + "], will not attempt to detach object of type ["
                + ((value != null) ? value.getClass().getName() : "N/A")
                + "]. This may cause serialization errors later. "
                + "You can try to work around this by setting the system property [" + DEPTH_ALLOWED_SYSPROP
                + "] to a value higher than [" + depth + "] or you can set the system property ["
                + THROW_EXCEPTION_ON_DEPTH_LIMIT_SYSPROP + "] to 'false'";
            LOG.warn(warningMessage);
            if (throwExceptionOnDepthLimit) {
                throw new IllegalStateException(warningMessage);
            }
            return;
        }

        if (null == value) {
            return;
        }

        // System.identityHashCode is a hash code, and therefore not guaranteed to be unique. And we've seen this
        // be the case.  So, we use it to try and avoid duplicating work, but handle the case when two objects may
        // have an identity crisis.
        Integer valueIdentity = hashCodeGenerator.getHashCode(value);
        Object checkedObject = checkedObjectMap.get(valueIdentity);

        if (null == checkedObject) {
            // if we have not yet encountered an object with this hash, store it in our map and start scrubbing            
            checkedObjectMap.put(valueIdentity, value);

        } else if (value == checkedObject) {
            // if we have scrubbed this already, no more work to be done            
            return;

        } else {
            // we have a situation where multiple objects have the same identity hashcode, work with our
            // collision map to decide whether it needs to be scrubbed and add if necessary.
            // Note that this code block is infrequently hit, it is by design that we've pushed the extra
            // work, map, etc, involved for this infrequent case into its own block. The standard cases must
            // be as fast and lean as possible.

            boolean alreadyDetached = false;
            List<Object> collisionObjects = checkedObjectCollisionMap.get(valueIdentity);

            if (null == collisionObjects) {
                // if this is the 2nd occurrence for this hash, create a new map entry                
                collisionObjects = new ArrayList<Object>(1);
                checkedObjectCollisionMap.put(valueIdentity, collisionObjects);

            } else {
                // if we have scrubbed this already, no more work to be done                
                for (Object collisionObject : collisionObjects) {
                    if (value == collisionObject) {
                        alreadyDetached = true;
                        break;
                    }
                }
            }

            if (LOG.isDebugEnabled()) {
                StringBuilder message = new StringBuilder("\n\tIDENTITY HASHCODE COLLISION [hash=");
                message.append(valueIdentity);
                message.append(", alreadyDetached=");
                message.append(alreadyDetached);
                message.append("]");
                message.append("\n\tCurrent  : ");
                message.append(value.getClass().getName());
                message.append("\n\t    ");
                message.append(value);
                message.append("\n\tPrevious : ");
                message.append(checkedObject.getClass().getName());
                message.append("\n\t    ");
                message.append(checkedObject);
                for (Object collisionObject : collisionObjects) {
                    message.append("\n\tPrevious : ");
                    message.append(collisionObject.getClass().getName());
                    message.append("\n\t    ");
                    message.append(collisionObject);
                }

                LOG.debug(message);
            }

            // now that we've done our logging, if already detached we're done. Otherwise add to the list of collision
            // objects for this hash, and start scrubbing
            if (alreadyDetached) {
                return;
            }

            collisionObjects.add(value);
        }

        // Perform the detaching
        if (value instanceof Object[]) {
            Object[] objArray = (Object[]) value;
            for (int i = 0; i < objArray.length; i++) {
                Object listEntry = objArray[i];
                Object replaceEntry = replaceObject(listEntry);
                if (replaceEntry != null) {
                    objArray[i] = replaceEntry;
                }
                nullOutUninitializedFields(objArray[i], checkedObjectMap, checkedObjectCollisionMap, depth + 1,
                    serializationType);
            }
        } else if (value instanceof List) {
            // Null out any entries in initialized collections
            ListIterator i = ((List) value).listIterator();
            while (i.hasNext()) {
                Object val = i.next();
                Object replace = replaceObject(val);
                if (replace != null) {
                    val = replace;
                    i.set(replace);
                }
                nullOutUninitializedFields(val, checkedObjectMap, checkedObjectCollisionMap, depth + 1,
                    serializationType);
            }

        } else if (value instanceof Collection) {
            Collection collection = (Collection) value;
            Collection itemsToBeReplaced = new ArrayList();
            Collection replacementItems = new ArrayList();
            for (Object item : collection) {
                Object replacementItem = replaceObject(item);
                if (replacementItem != null) {
                    itemsToBeReplaced.add(item);
                    replacementItems.add(replacementItem);
                    item = replacementItem;
                }
                nullOutUninitializedFields(item, checkedObjectMap, checkedObjectCollisionMap, depth + 1,
                    serializationType);
            }
            collection.removeAll(itemsToBeReplaced);
            collection.addAll(replacementItems); // watch out! if this collection is a Set, HashMap$MapSet doesn't support addAll. See BZ 688000
        } else if (value instanceof Map) {
            Map originalMap = (Map) value;
            HashMap<Object, Object> replaceMap = new HashMap<Object, Object>();
            for (Iterator i = originalMap.keySet().iterator(); i.hasNext();) {
                // get original key and value - these might be hibernate proxies
                Object originalKey = i.next();
                Object originalKeyValue = originalMap.get(originalKey);

                // replace with non-hibernate classes, if appropriate (will be null otherwise)
                Object replaceKey = replaceObject(originalKey);
                Object replaceValue = replaceObject(originalKeyValue);

                // if either original key or original value was a hibernate proxy object, we have to 
                // remove it from the original map, and remember the replacement objects for later
                if (replaceKey != null || replaceValue != null) {
                    Object newKey = (replaceKey != null) ? replaceKey : originalKey;
                    Object newValue = (replaceValue != null) ? replaceValue : originalKeyValue;
                    replaceMap.put(newKey, newValue);
                    i.remove();
                }
            }

            // all hibernate proxies have been removed, we need to replace them with their
            // non-proxy object representations that we got from replaceObject() calls
            originalMap.putAll(replaceMap);

            // now go through each item in the map and null out their internal fields
            for (Object key : originalMap.keySet()) {
                nullOutUninitializedFields(originalMap.get(key), checkedObjectMap, checkedObjectCollisionMap,
                    depth + 1, serializationType);
                nullOutUninitializedFields(key, checkedObjectMap, checkedObjectCollisionMap, depth + 1,
                    serializationType);
            }
        } else if (value instanceof Enum) {
            // don't need to detach enums, treat them as special objects
            return;
        }

        if (serializationType == SerializationType.JAXB) {
            XmlAccessorType at = value.getClass().getAnnotation(XmlAccessorType.class);
            if (at != null && at.value() == XmlAccessType.FIELD) {
                nullOutFieldsByFieldAccess(value, checkedObjectMap, checkedObjectCollisionMap, depth, serializationType);
            } else {
                nullOutFieldsByAccessors(value, checkedObjectMap, checkedObjectCollisionMap, depth, serializationType);
            }
        } else if (serializationType == SerializationType.SERIALIZATION) {
            nullOutFieldsByFieldAccess(value, checkedObjectMap, checkedObjectCollisionMap, depth, serializationType);
        }

    }

    private static void nullOutFieldsByFieldAccess(Object object, Map<Integer, Object> checkedObjects,
        Map<Integer, List<Object>> checkedObjectCollisionMap, int depth, SerializationType serializationType)
        throws Exception {

        Class tmpClass = object.getClass();
        List<Field> fieldsToClean = new ArrayList<Field>();
        while (tmpClass != null && tmpClass != Object.class) {
            Field[] declaredFields = tmpClass.getDeclaredFields();
            for (Field declaredField : declaredFields) {
                // do not process static final or transient fields since they won't be serialized anyway
                int modifiers = declaredField.getModifiers();
                if (!((Modifier.isFinal(modifiers) && Modifier.isStatic(modifiers)) || Modifier.isTransient(modifiers))) {
                    fieldsToClean.add(declaredField);
                }
            }
            tmpClass = tmpClass.getSuperclass();
        }

        nullOutFieldsByFieldAccess(object, fieldsToClean, checkedObjects, checkedObjectCollisionMap, depth,
            serializationType);
    }

    private static void nullOutFieldsByFieldAccess(Object object, List<Field> classFields,
        Map<Integer, Object> checkedObjects, Map<Integer, List<Object>> checkedObjectCollisionMap, int depth,
        SerializationType serializationType) throws Exception {

        boolean accessModifierFlag = false;
        for (Field field : classFields) {
            accessModifierFlag = false;
            if (!field.isAccessible()) {
                field.setAccessible(true);
                accessModifierFlag = true;
            }

            Object fieldValue = field.get(object);

            if (fieldValue instanceof HibernateProxy) {

                Object replacement = null;
                String assistClassName = fieldValue.getClass().getName();
                if (assistClassName.contains("javassist") || assistClassName.contains("EnhancerByCGLIB")) {

                    Class assistClass = fieldValue.getClass();
                    try {
                        Method m = assistClass.getMethod("writeReplace");
                        replacement = m.invoke(fieldValue);

                        String assistNameDelimiter = assistClassName.contains("javassist") ? "_$$_" : "$$";

                        assistClassName = assistClassName.substring(0, assistClassName.indexOf(assistNameDelimiter));
                        if (!replacement.getClass().getName().contains("hibernate")) {
                            nullOutUninitializedFields(replacement, checkedObjects, checkedObjectCollisionMap,
                                depth + 1, serializationType);

                            field.set(object, replacement);
                        } else {
                            replacement = null;
                        }
                    } catch (Exception e) {
                        LOG.error("Unable to write replace object " + fieldValue.getClass(), e);
                    }
                }

                if (replacement == null) {

                    String className = ((HibernateProxy) fieldValue).getHibernateLazyInitializer().getEntityName();

                    //see if there is a context classloader we should use instead of the current one.
                    ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

                    Class clazz = contextClassLoader == null ? Class.forName(className) : Class.forName(className,
                        true, contextClassLoader);
                    Class[] constArgs = { Integer.class };
                    Constructor construct = null;

                    try {
                        construct = clazz.getConstructor(constArgs);
                        replacement = construct.newInstance((Integer) ((HibernateProxy) fieldValue)
                            .getHibernateLazyInitializer().getIdentifier());
                        field.set(object, replacement);
                    } catch (NoSuchMethodException nsme) {

                        try {
                            Field idField = clazz.getDeclaredField("id");
                            Constructor ct = clazz.getDeclaredConstructor();
                            ct.setAccessible(true);
                            replacement = ct.newInstance();
                            if (!idField.isAccessible()) {
                                idField.setAccessible(true);
                            }
                            idField.set(replacement, (Integer) ((HibernateProxy) fieldValue)
                                .getHibernateLazyInitializer().getIdentifier());
                        } catch (Exception e) {
                            e.printStackTrace();
                            LOG.error("No id constructor and unable to set field id for base bean " + className, e);
                        }

                        field.set(object, replacement);
                    }
                }

            } else {
                if (fieldValue instanceof org.hibernate.collection.PersistentCollection) {
                    // Replace hibernate specific collection types

                    if (!((org.hibernate.collection.PersistentCollection) fieldValue).wasInitialized()) {
                        field.set(object, null);
                    } else {

                        Object replacement = null;
                        boolean needToNullOutFields = true; // needed for BZ 688000
                        if (fieldValue instanceof Map) {
                            replacement = new HashMap((Map) fieldValue);
                        } else if (fieldValue instanceof List) {
                            replacement = new ArrayList((List) fieldValue);
                        } else if (fieldValue instanceof Set) {
                            ArrayList l = new ArrayList((Set) fieldValue); // cannot recurse Sets, see BZ 688000
                            nullOutUninitializedFields(l, checkedObjects, checkedObjectCollisionMap, depth + 1,
                                serializationType);
                            replacement = new HashSet(l); // convert it back to a Set since that's the type of the real collection, see BZ 688000
                            needToNullOutFields = false;
                        } else if (fieldValue instanceof Collection) {
                            replacement = new ArrayList((Collection) fieldValue);
                        }
                        setField(object, field.getName(), replacement);

                        if (needToNullOutFields) {
                            nullOutUninitializedFields(replacement, checkedObjects, checkedObjectCollisionMap,
                                depth + 1, serializationType);
                        }
                    }

                } else {
                    if (fieldValue != null
                        && (fieldValue.getClass().getName().contains("org.rhq") || fieldValue instanceof Collection
                            || fieldValue instanceof Object[] || fieldValue instanceof Map))
                        nullOutUninitializedFields((fieldValue), checkedObjects, checkedObjectCollisionMap, depth + 1,
                            serializationType);
                }
            }
            if (accessModifierFlag) {
                field.setAccessible(false);
            }
        }

    }

    private static Object replaceObject(Object object) {
        Object replacement = null;

        if (object instanceof HibernateProxy) {
            if (object.getClass().getName().contains("javassist")) {
                Class assistClass = object.getClass();
                try {
                    Method m = assistClass.getMethod("writeReplace");
                    replacement = m.invoke(object);

                } catch (Exception e) {
                    LOG.error("Unable to write replace object " + object.getClass(), e);
                }
            }
        }
        return replacement;
    }

    private static void nullOutFieldsByAccessors(Object value, Map<Integer, Object> checkedObjects,
        Map<Integer, List<Object>> checkedObjectCollisionMap, int depth, SerializationType serializationType)
        throws Exception {
        // Null out any collections that aren't loaded
        BeanInfo bi = Introspector.getBeanInfo(value.getClass(), Object.class);

        PropertyDescriptor[] pds = bi.getPropertyDescriptors();
        for (PropertyDescriptor pd : pds) {
            Object propertyValue = null;
            try {
                propertyValue = pd.getReadMethod().invoke(value);
            } catch (Throwable lie) {
                if (LOG.isDebugEnabled()) {
                    LOG.debug("Couldn't load: " + pd.getName() + " off of " + value.getClass().getSimpleName(), lie);
                }
            }

            if (!Hibernate.isInitialized(propertyValue)) {
                try {
                    if (LOG.isDebugEnabled()) {
                        LOG.debug("Nulling out: " + pd.getName() + " off of " + value.getClass().getSimpleName());
                    }

                    Method writeMethod = pd.getWriteMethod();
                    if ((writeMethod != null) && (writeMethod.getAnnotation(XmlTransient.class) == null)) {
                        pd.getWriteMethod().invoke(value, new Object[] { null });
                    } else {
                        nullOutField(value, pd.getName());
                    }
                } catch (Exception lie) {
                    LOG.debug("Couldn't null out: " + pd.getName() + " off of " + value.getClass().getSimpleName()
                        + " trying field access", lie);
                    nullOutField(value, pd.getName());
                }
            } else {
                if ((propertyValue instanceof Collection)
                    || ((propertyValue != null) && propertyValue.getClass().getName().startsWith("org.rhq.core.domain"))) {
                    nullOutUninitializedFields(propertyValue, checkedObjects, checkedObjectCollisionMap, depth + 1,
                        serializationType);
                }
            }
        }
    }

    private static void setField(Object object, String fieldName, Object newValue) {
        try {
            Field f = object.getClass().getDeclaredField(fieldName);
            if (f != null) {
                // try to set the field this way
                f.setAccessible(true);
                f.set(object, newValue);
            }
        } catch (NoSuchFieldException e) {
            // ignore this
        } catch (IllegalAccessException e) {
            // ignore this
        }
    }

    private static void nullOutField(Object value, String fieldName) {
        try {
            Field f = value.getClass().getDeclaredField(fieldName);
            if (f != null) {
                // try to set the field this way
                f.setAccessible(true);
                f.set(value, null);
            }
        } catch (NoSuchFieldException e) {
            // ignore this
        } catch (IllegalAccessException e) {
            // ignore this
        }
    }
}