/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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 org.apache.cxf.aegis.type.basic;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.xml.namespace.QName;

import org.apache.cxf.aegis.DatabindingException;
import org.apache.cxf.aegis.type.AegisType;
import org.apache.cxf.aegis.type.TypeCreator;
import org.apache.cxf.aegis.type.TypeMapping;
import org.apache.cxf.common.util.ReflectionUtil;

public class BeanTypeInfo {
    private Map<QName, QName> mappedName2typeName = new HashMap<>();
    private Map<QName, String> mappedName2pdName = new HashMap<>();
    private Map<QName, AegisType> mappedName2type = new HashMap<>();
    private Class<?> beanClass;
    private List<QName> attributes = new ArrayList<>();
    private List<QName> elements = new ArrayList<>();
    private PropertyDescriptor[] descriptors;
    private TypeMapping typeMapping;
    private volatile boolean initialized;
    private String defaultNamespace;
    private int minOccurs;
    private boolean nillable = true;
    private boolean isExtension;
    private boolean qualifyAttributes;
    private boolean qualifyElements = true;

    /**
     * extensibleElements means adding xs:any to WSDL Complex AegisType Definition
     */
    private boolean extensibleElements = true;

    /**
     * extensibleAttributes means adding xs:anyAttribute to WSDL Complex AegisType
     * Definition
     */
    private boolean extensibleAttributes = true;

    public BeanTypeInfo(Class<?> typeClass, String defaultNamespace) {
        this.beanClass = typeClass;
        this.defaultNamespace = defaultNamespace;

        initializeProperties();
    }

    /**
     * Create a BeanTypeInfo class.
     *
     * @param typeClass
     * @param defaultNamespace
     * @param initiallize If true attempt default property/xml mappings.
     */
    public BeanTypeInfo(Class<?> typeClass, String defaultNamespace, boolean initialize) {
        this.beanClass = typeClass;
        this.defaultNamespace = defaultNamespace;

        initializeProperties();
        initialized = !initialize;
    }

    public String getDefaultNamespace() {
        return defaultNamespace;
    }

    public void initialize() {
        try {
            if (!initialized) {
                initializeSync();
            }
        } catch (Exception e) {
            if (e instanceof DatabindingException) {
                throw (DatabindingException)e;
            }
            throw new DatabindingException("Couldn't create TypeInfo.", e);
        }
    }

    private synchronized void initializeSync() {
        if (!initialized) {
            for (int i = 0; i < descriptors.length; i++) {
                // Don't map the property unless there is a read property
                if (isMapped(descriptors[i])) {
                    mapProperty(descriptors[i]);
                }
            }
            initialized = true;
        }
    }

    public boolean isMapped(PropertyDescriptor pd) {
        return pd.getReadMethod() != null;
    }

    protected void mapProperty(PropertyDescriptor pd) {
        String name = pd.getName();

        if (isAttribute(pd)) {
            mapAttribute(name, createMappedName(pd, qualifyAttributes));
        } else if (isElement(pd)) {
            mapElement(name, createMappedName(pd, qualifyElements));
        }
    }

    protected PropertyDescriptor[] getPropertyDescriptors() {
        return descriptors;
    }

    protected PropertyDescriptor getPropertyDescriptor(String name) {
        for (int i = 0; i < descriptors.length; i++) {
            if (descriptors[i].getName().equals(name)) {
                return descriptors[i];
            }
        }

        return null;
    }

    /**
     * Get the type class for the field with the specified QName.
     */
    public AegisType getType(QName name) {
        // 1. Try a prexisting mapped type
        AegisType type = mappedName2type.get(name);

        // 2. Try to get the type by its name, if there is one
        if (type == null) {
            QName typeName = getMappedTypeName(name);
            if (typeName != null) {
                type = getTypeMapping().getType(typeName);

                if (type != null) {
                    mapType(name, type);
                }
            }
        }

        // 3. Create the type from the property descriptor and map it
        if (type == null) {
            PropertyDescriptor desc;
            try {
                desc = getPropertyDescriptorFromMappedName(name);
            } catch (Exception e) {
                if (e instanceof DatabindingException) {
                    throw (DatabindingException)e;
                }
                throw new DatabindingException("Couldn't get properties.", e);
            }

            if (desc == null) {
                return null;
            }

            try {
                TypeMapping tm = getTypeMapping();
                TypeCreator tc = tm.getTypeCreator();
                type = tc.createType(desc);
            } catch (DatabindingException e) {
                e.prepend("Couldn't create type for property " + desc.getName() + " on " + getTypeClass());

                throw e;
            }

            // second part is possible workaround for XFIRE-586
            if (registerType(desc)) {
                getTypeMapping().register(type);
            }

            mapType(name, type);
        }

        if (type == null) {
            throw new DatabindingException("Couldn't find type for property " + name);
        }

        return type;
    }

    protected boolean registerType(PropertyDescriptor desc) {
        return true;
    }

    public void mapType(QName name, AegisType type) {
        mappedName2type.put(name, type);
    }

    private QName getMappedTypeName(QName name) {
        return mappedName2typeName.get(name);
    }

    public TypeMapping getTypeMapping() {
        return typeMapping;
    }

    public void setTypeMapping(TypeMapping typeMapping) {
        this.typeMapping = typeMapping;
    }

    /**
     * Specifies the name of the property as it shows up in the xml schema. This
     * method just returns <code>propertyDescriptor.getName();</code>
     *
     * @param desc
     * @return
     */
    protected QName createMappedName(PropertyDescriptor desc, boolean qualified) {
        if (qualified) {
            return new QName(getDefaultNamespace(), desc.getName());
        }
        return new QName(null, desc.getName());
    }

    public void mapAttribute(String property, QName mappedName) {
        mappedName2pdName.put(mappedName, property);
        attributes.add(mappedName);
    }

    public void mapElement(String property, QName mappedName) {
        mappedName2pdName.put(mappedName, property);
        elements.add(mappedName);
    }

    /**
     * Specifies the SchemaType for a particular class.
     *
     * @param mappedName
     * @param type
     */
    public void mapTypeName(QName mappedName, QName type) {
        mappedName2typeName.put(mappedName, type);
    }

    private void initializeProperties() {
        BeanInfo beanInfo = null;
        try {
            if (beanClass.isInterface() || beanClass.isPrimitive()) {
                descriptors = getInterfacePropertyDescriptors(beanClass);
            } else if (beanClass.isEnum()) {
                // do nothing
            } else if (beanClass == Object.class || beanClass == Throwable.class) {
                // do nothing
            } else if (RuntimeException.class.isAssignableFrom(beanClass)) {
                beanInfo = Introspector.getBeanInfo(beanClass, RuntimeException.class);
            } else if (Exception.class.isAssignableFrom(beanClass) && !Exception.class.equals(beanClass)) {
                beanInfo = Introspector.getBeanInfo(beanClass, Exception.class);
            } else if (Throwable.class.isAssignableFrom(beanClass)) {
                beanInfo = Introspector.getBeanInfo(beanClass, Throwable.class);
            } else {
                beanInfo = Introspector.getBeanInfo(beanClass, Object.class);
            }
        } catch (IntrospectionException e) {
            throw new DatabindingException("Couldn't introspect interface.", e);
        }

        if (beanInfo != null) {
            PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors();
            if (propertyDescriptors != null) {
                // see comments on this function.
                descriptors = ReflectionUtil.getPropertyDescriptorsAvoidSunBug(getClass(),
                                                                               beanInfo,
                                                                               beanClass,
                                                                               propertyDescriptors);
            }
        }

        if (descriptors == null) {
            descriptors = new PropertyDescriptor[0];
        }
        Arrays.sort(descriptors, new Comparator<PropertyDescriptor>() {
            public int compare(PropertyDescriptor o1, PropertyDescriptor o2) {
                return o1.getName().compareTo(o2.getName());
            }
        });
    }

    private PropertyDescriptor[] getInterfacePropertyDescriptors(Class<?> clazz) {
        List<PropertyDescriptor> pds = new ArrayList<>();

        getInterfacePropertyDescriptors(clazz, pds, new HashSet<Class<?>>());

        return pds.toArray(new PropertyDescriptor[0]);
    }

    private void getInterfacePropertyDescriptors(Class<?> clazz, List<PropertyDescriptor> pds,
                                                 Set<Class<?>> classes) {
        if (classes.contains(clazz)) {
            return;
        }

        classes.add(clazz);

        try {
            Class<?>[] interfaces = clazz.getInterfaces();

            /**
             * add base interface information
             */
            BeanInfo info = Introspector.getBeanInfo(clazz);
            for (int j = 0; j < info.getPropertyDescriptors().length; j++) {
                PropertyDescriptor pd = info.getPropertyDescriptors()[j];
                if (!containsPropertyName(pds, pd.getName())) {
                    pds.add(pd);
                }
            }

            /**
             * add extended interface information
             */
            for (int i = 0; i < interfaces.length; i++) {
                getInterfacePropertyDescriptors(interfaces[i], pds, classes);
            }
        } catch (IntrospectionException e) {
            // do nothing
        }
    }

    private boolean containsPropertyName(List<PropertyDescriptor> pds, String name) {
        for (Iterator<PropertyDescriptor> itr = pds.iterator(); itr.hasNext();) {
            PropertyDescriptor pd = itr.next();
            if (pd.getName().equals(name)) {
                return true;
            }
        }
        return false;
    }

    public PropertyDescriptor getPropertyDescriptorFromMappedName(QName name) {
        return getPropertyDescriptor(getPropertyNameFromMappedName(name));
    }

    protected boolean isAttribute(PropertyDescriptor desc) {
        return false;
    }

    protected boolean isElement(PropertyDescriptor desc) {
        return true;
    }

    protected boolean isSerializable(PropertyDescriptor desc) {
        return true;
    }

    protected Class<?> getTypeClass() {
        return beanClass;
    }

    /**
     * Nillable is only allowed if the actual property is Nullable
     *
     * @param name
     * @return
     */
    public boolean isNillable(QName name) {
        AegisType type = getType(name);
        if (!type.isNillable()) {
            return false;
        }
        return nillable;
    }

    /**
     * Return the minOccurs value. When there is no XML file or annotation (the situation
     * if we are running from the base class here), there is no source for the
     * minOccurs parameter except the default, which is supplied from the overall Aegis options.
     * @param name Element QName
     * @return
     */
    public int getMinOccurs(QName name) {
        return minOccurs;
    }

    /**
     * Return the maxOccurs value. When there is no XML file or annotation (the situation
     * if we are in the base class here), there is no per-element source for this item,
     * and the value is always 1.
     * @param name Element QName
     * @return 1
     */
    public int getMaxOccurs(QName name) {
        return 1;
    }

    public long getMinOccurs() {
        return minOccurs;
    }

    public void setDefaultMinOccurs(int m) {
        this.minOccurs = m;
    }

    public void setDefaultNillable(boolean n) {
        this.nillable = n;
    }

    private String getPropertyNameFromMappedName(QName name) {
        return mappedName2pdName.get(name);
    }

    public List<QName> getAttributes() {
        return attributes;
    }

    public List<QName> getElements() {
        return elements;
    }

    public boolean isExtensibleElements() {
        return extensibleElements;
    }

    public void setExtensibleElements(boolean futureProof) {
        this.extensibleElements = futureProof;
    }

    public boolean isExtensibleAttributes() {
        return extensibleAttributes;
    }

    public void setExtensibleAttributes(boolean extensibleAttributes) {
        this.extensibleAttributes = extensibleAttributes;
    }

    public void setExtension(boolean extension) {
        this.isExtension = extension;
    }

    public boolean isExtension() {
        return isExtension;
    }

    /** * @return Returns the qualifyAttributes.
     */
    public boolean isQualifyAttributes() {
        return qualifyAttributes;
    }

    /**
     * @param qualifyAttributes The qualifyAttributes to set.
     */
    public void setQualifyAttributes(boolean qualifyAttributes) {
        this.qualifyAttributes = qualifyAttributes;
    }

    /** * @return Returns the qualifyElements.
     */
    public boolean isQualifyElements() {
        return qualifyElements;
    }

    /**
     * @param qualifyElements The qualifyElements to set.
     */
    public void setQualifyElements(boolean qualifyElements) {
        this.qualifyElements = qualifyElements;
    }

}
